From de78645f5f54e662c5e266a4375a516bbcf9c2f5 Mon Sep 17 00:00:00 2001 From: Baudbot Date: Wed, 18 Feb 2026 21:11:48 -0500 Subject: [PATCH 1/3] =?UTF-8?q?broker:=20add=20Slack=20message=20broker=20?= =?UTF-8?q?=E2=80=94=20Phase=201=20(Cloudflare=20Worker)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-contained Cloudflare Worker at slack-broker/ that routes messages between Slack workspaces and individual baudbot servers with end-to-end encryption. Crypto layer: - crypto_box_seal (sealed boxes) for inbound Slack→server path - crypto_box (authenticated encryption) for outbound server→Slack path - Ed25519 signatures on all envelopes for authentication - All primitives via tweetnacl (lightweight, no native deps, Workers-compatible) Routing: - KV-backed workspace registry (workspace_id → server config) - Sealed box encrypt + forward to server callback URL - 10s timeout, graceful error handling, no message queuing Slack integration: - Events API handler with HMAC-SHA256 signature verification - 5-minute replay protection on all timestamps - OAuth install flow with state parameter + auth code generation - Slack Web API helpers (postMessage, reactions.add, chat.update) API endpoints: - POST /api/register — server registration with auth code verification - DELETE /api/register — server unlink with signature verification - POST /api/send — outbound encrypted message → decrypt → post to Slack - GET /api/broker-pubkey — public key distribution Security: - Broker CANNOT decrypt inbound messages (sealed box) - Outbound plaintext zeroed immediately after posting to Slack - Auth codes hashed with SHA-256 before storage - Callback URLs must use HTTPS - Constant-time signature comparison Tests: 54 tests across 3 suites (crypto, routing, integration) Docs: AGENTS.md and architecture.md updated with slack-broker layout --- AGENTS.md | 12 +- docs/architecture.md | 3 +- slack-broker/README.md | 164 ++ slack-broker/package-lock.json | 3014 +++++++++++++++++++++++++ slack-broker/package.json | 23 + slack-broker/src/api/register.ts | 176 ++ slack-broker/src/api/send.ts | 216 ++ slack-broker/src/crypto/box.ts | 82 + slack-broker/src/crypto/seal.ts | 89 + slack-broker/src/crypto/verify.ts | 86 + slack-broker/src/index.ts | 141 ++ slack-broker/src/routing/forward.ts | 101 + slack-broker/src/routing/registry.ts | 141 ++ slack-broker/src/slack/api.ts | 103 + slack-broker/src/slack/events.ts | 161 ++ slack-broker/src/slack/oauth.ts | 167 ++ slack-broker/src/util/encoding.ts | 43 + slack-broker/test/crypto.test.ts | 269 +++ slack-broker/test/integration.test.ts | 314 +++ slack-broker/test/routing.test.ts | 251 ++ slack-broker/tsconfig.json | 19 + slack-broker/vitest.config.ts | 9 + slack-broker/wrangler.toml | 32 + 23 files changed, 5614 insertions(+), 2 deletions(-) create mode 100644 slack-broker/README.md create mode 100644 slack-broker/package-lock.json create mode 100644 slack-broker/package.json create mode 100644 slack-broker/src/api/register.ts create mode 100644 slack-broker/src/api/send.ts create mode 100644 slack-broker/src/crypto/box.ts create mode 100644 slack-broker/src/crypto/seal.ts create mode 100644 slack-broker/src/crypto/verify.ts create mode 100644 slack-broker/src/index.ts create mode 100644 slack-broker/src/routing/forward.ts create mode 100644 slack-broker/src/routing/registry.ts create mode 100644 slack-broker/src/slack/api.ts create mode 100644 slack-broker/src/slack/events.ts create mode 100644 slack-broker/src/slack/oauth.ts create mode 100644 slack-broker/src/util/encoding.ts create mode 100644 slack-broker/test/crypto.test.ts create mode 100644 slack-broker/test/integration.test.ts create mode 100644 slack-broker/test/routing.test.ts create mode 100644 slack-broker/tsconfig.json create mode 100644 slack-broker/vitest.config.ts create mode 100644 slack-broker/wrangler.toml diff --git a/AGENTS.md b/AGENTS.md index a5e34e0..88ef924 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,9 +49,19 @@ control-plane/ server.mjs admin-owned web dashboard + API (port 28800) server.test.mjs control plane tests slack-bridge/ - bridge.mjs Slack ↔ agent bridge + bridge.mjs Slack ↔ agent bridge (direct mode — Socket Mode) security.mjs 🔒 content wrapping, rate limiting, auth security.test.mjs 🔒 tests for security module +slack-broker/ + src/ Cloudflare Worker source (broker mode — Events API) + index.ts Worker entry point + request router + slack/ Slack signature verification, OAuth, API helpers + crypto/ Sealed boxes, authenticated encryption, signatures + routing/ KV-backed workspace registry + event forwarding + api/ Server registration + outbound message handling + test/ Vitest test suites (crypto, routing, integration) + wrangler.toml Cloudflare Workers config (KV namespaces, secrets) + package.json Dependencies: tweetnacl, vitest setup.sh one-time system setup (creates user, firewall, etc.) start.sh agent launcher (deployed to ~/runtime/start.sh) ``` diff --git a/docs/architecture.md b/docs/architecture.md index 77df28f..ebe2754 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,7 +10,8 @@ admin user │ ├── bin/ │ ├── pi/extensions/ │ ├── pi/skills/ -│ └── slack-bridge/ +│ ├── slack-bridge/ # direct Slack integration (Socket Mode) +│ └── slack-broker/ # shared Slack broker (Cloudflare Worker) root-managed releases ├── /opt/baudbot/ diff --git a/slack-broker/README.md b/slack-broker/README.md new file mode 100644 index 0000000..dcf453f --- /dev/null +++ b/slack-broker/README.md @@ -0,0 +1,164 @@ +# Slack Broker + +A Cloudflare Worker that routes messages between Slack workspaces and individual baudbot servers. All messages are end-to-end encrypted — the broker cannot read inbound messages and only decrypts outbound messages transiently to post them to Slack. + +## Architecture + +``` +Slack Workspace ──Events API──► Cloudflare Worker ──sealed box──► Baudbot Server + (Slack Broker) +Slack Workspace ◄──Slack API──── Cloudflare Worker ◄──crypto_box── Baudbot Server +``` + +**Inbound (Slack → Server):** Slack sends events to the broker in plaintext (unavoidable Slack constraint). The broker encrypts the payload using `crypto_box_seal` (sealed box) with the server's public key, then forwards it. The broker **cannot decrypt** sealed boxes — only the server's private key can. + +**Outbound (Server → Slack):** The server encrypts the message body with `crypto_box` (authenticated encryption) using the broker's public key. Routing metadata (channel, thread_ts) stays in cleartext so the broker can route without decrypting. The broker decrypts the body, posts to Slack, and immediately zeroes the plaintext from memory. + +## Setup + +### Prerequisites + +- [Node.js](https://nodejs.org/) 18+ +- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/) (`npm i -g wrangler`) +- A Cloudflare account + +### 1. Create KV Namespaces + +```bash +wrangler kv namespace create WORKSPACE_ROUTING +wrangler kv namespace create OAUTH_STATE +``` + +Update the namespace IDs in `wrangler.toml`. + +### 2. Set Secrets + +```bash +# Generate a 32-byte seed for the broker's keypair +openssl rand -base64 32 | wrangler secret put BROKER_PRIVATE_KEY + +# From your Slack app configuration +wrangler secret put SLACK_CLIENT_ID +wrangler secret put SLACK_CLIENT_SECRET +wrangler secret put SLACK_SIGNING_SECRET +``` + +### 3. Deploy + +```bash +cd slack-broker +npm install +npm run deploy +``` + +### 4. Configure Slack App + +Point your Slack app's Event Subscriptions request URL to: +``` +https://.workers.dev/slack/events +``` + +Set the OAuth redirect URL to: +``` +https://.workers.dev/slack/oauth/callback +``` + +## API Reference + +### Public Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/health` | Health check | +| `GET` | `/api/broker-pubkey` | Get broker's public keys | +| `POST` | `/slack/events` | Slack Events API webhook | +| `GET` | `/slack/oauth/install` | Start OAuth install flow | +| `GET` | `/slack/oauth/callback` | Handle OAuth callback | + +### Server-to-Broker (authenticated) + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/register` | Register a baudbot server for a workspace | +| `DELETE` | `/api/register` | Unlink a server (requires signature) | +| `POST` | `/api/send` | Send an encrypted message to Slack | + +### Registration Flow + +1. User visits `/slack/oauth/install` → redirected to Slack → authorizes app +2. Callback stores workspace with "pending" status, returns an auth code +3. Server calls `POST /api/register` with the auth code to link itself +4. Workspace status becomes "active" — events start flowing + +### Outbound Message Format + +```json +{ + "workspace_id": "T09192W1Z34", + "action": "chat.postMessage", + "routing": { + "channel": "C0A2G6TSDL6", + "thread_ts": "1771464783.614839" + }, + "encrypted_body": "", + "nonce": "", + "timestamp": 1771465000, + "signature": "" +} +``` + +Supported actions: `chat.postMessage`, `reactions.add`, `chat.update`. + +## Encryption Details + +| Primitive | Use | Library | +|-----------|-----|---------| +| `crypto_box_seal` (X25519 + XSalsa20-Poly1305) | Inbound: Slack → server | tweetnacl | +| `crypto_box` (X25519 + XSalsa20-Poly1305) | Outbound: server → Slack | tweetnacl | +| Ed25519 | Envelope signatures | tweetnacl | +| HMAC-SHA256 | Slack request verification | Web Crypto API | + +### Key Types + +- **X25519 keypair** — encryption/decryption (sealed boxes + authenticated encryption) +- **Ed25519 keypair** — signing/verification (envelope authentication) +- Both derived from the same 32-byte seed (`BROKER_PRIVATE_KEY`) + +## Security Properties + +- ✅ Inbound messages encrypted in transit (sealed box, only server can decrypt) +- ✅ Outbound message body encrypted in transit (authenticated, broker decrypts transiently) +- ✅ Broker cannot persist message content (by design) +- ✅ Broker cannot read inbound messages (sealed box — no broker private key involved) +- ✅ Server authenticates broker (broker signs envelopes) +- ✅ Broker authenticates server (server signs outbound requests) +- ✅ Replay protection (timestamps + nonces on all messages) +- ✅ Auth code verification for server registration +- ❌ Perfect forward secrecy (would need session keys — future enhancement) + +### What the Broker Can See + +- Routing metadata: workspace_id, channel, thread_ts, timestamps +- Outbound message content: **transiently** (decrypted in memory to post to Slack, then zeroed) + +### What the Broker Cannot See + +- Inbound message content (sealed box encryption) +- Historical messages (nothing is stored) + +## Development + +```bash +npm install +npm test # Run tests +npm run dev # Start local dev server +npm run typecheck # TypeScript type checking +``` + +## Testing + +54 tests across 3 suites: + +- **crypto.test.ts** — Sealed boxes, authenticated encryption, signatures, encoding +- **routing.test.ts** — KV registry CRUD, auth code hashing, event forwarding +- **integration.test.ts** — End-to-end flows, Slack signature verification, replay protection diff --git a/slack-broker/package-lock.json b/slack-broker/package-lock.json new file mode 100644 index 0000000..831631f --- /dev/null +++ b/slack-broker/package-lock.json @@ -0,0 +1,3014 @@ +{ + "name": "slack-broker", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "slack-broker", + "version": "0.1.0", + "dependencies": { + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241230.0", + "typescript": "^5.7.0", + "vitest": "^2.1.0", + "wrangler": "^3.99.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", + "integrity": "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.0.2.tgz", + "integrity": "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.14", + "workerd": "^1.20250124.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250718.0.tgz", + "integrity": "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250718.0.tgz", + "integrity": "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250718.0.tgz", + "integrity": "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250718.0.tgz", + "integrity": "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250718.0.tgz", + "integrity": "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260219.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260219.0.tgz", + "integrity": "sha512-jL2BNnDqbKXDrxhtKx+wVmQpv/P6w8J4WVFiuT9OMEPsw8V2TfTozoWTcCZ2AhE09yK406xQFE4mBq9IIgobuw==", + "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.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild-plugins/node-globals-polyfill": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", + "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild-plugins/node-modules-polyfill": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.2.2.tgz", + "integrity": "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==", + "dev": true, + "license": "ISC", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "rollup-plugin-node-polyfills": "^0.2.1" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "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/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/miniflare": { + "version": "3.20250718.3", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", + "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "stoppable": "1.1.0", + "undici": "^5.28.5", + "workerd": "1.20250718.0", + "ws": "8.18.0", + "youch": "3.3.4", + "zod": "3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-inject": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", + "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1", + "magic-string": "^0.25.3", + "rollup-pluginutils": "^2.8.1" + } + }, + "node_modules/rollup-plugin-inject/node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup-plugin-inject/node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/rollup-plugin-node-polyfills": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", + "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-plugin-inject": "^3.0.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/rollup-pluginutils/node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.1.8.tgz", + "integrity": "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, + "node_modules/tweetnacl-util": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", + "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==", + "license": "Unlicense" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.14", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", + "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.1", + "ohash": "^2.0.10", + "pathe": "^2.0.3", + "ufo": "^1.5.4" + } + }, + "node_modules/unenv/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/workerd": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", + "integrity": "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20250718.0", + "@cloudflare/workerd-darwin-arm64": "1.20250718.0", + "@cloudflare/workerd-linux-64": "1.20250718.0", + "@cloudflare/workerd-linux-arm64": "1.20250718.0", + "@cloudflare/workerd-windows-64": "1.20250718.0" + } + }, + "node_modules/wrangler": { + "version": "3.114.17", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.114.17.tgz", + "integrity": "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.3.4", + "@cloudflare/unenv-preset": "2.0.2", + "@esbuild-plugins/node-globals-polyfill": "0.2.3", + "@esbuild-plugins/node-modules-polyfill": "0.2.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.17.19", + "miniflare": "3.20250718.3", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.14", + "workerd": "1.20250718.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=16.17.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20250408.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", + "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", + "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", + "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", + "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", + "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", + "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", + "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", + "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", + "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", + "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", + "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", + "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", + "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", + "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", + "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", + "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", + "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", + "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", + "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", + "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", + "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", + "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", + "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^0.7.1", + "mustache": "^4.2.0", + "stacktracey": "^2.1.8" + } + }, + "node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/slack-broker/package.json b/slack-broker/package.json new file mode 100644 index 0000000..f5ce975 --- /dev/null +++ b/slack-broker/package.json @@ -0,0 +1,23 @@ +{ + "name": "slack-broker", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241230.0", + "typescript": "^5.7.0", + "vitest": "^2.1.0", + "wrangler": "^3.99.0" + } +} diff --git a/slack-broker/src/api/register.ts b/slack-broker/src/api/register.ts new file mode 100644 index 0000000..d68c06b --- /dev/null +++ b/slack-broker/src/api/register.ts @@ -0,0 +1,176 @@ +/** + * Server registration endpoint. + * + * A baudbot server calls POST /api/register to link itself to a workspace. + * The request must include the auth_code from the OAuth flow to prove + * workspace ownership. + * + * Flow: + * 1. Server sends: workspace_id, server_pubkey, server_signing_pubkey, + * server_callback_url, auth_code + * 2. Broker verifies auth_code against stored hash + * 3. Broker activates the workspace with server details + * 4. Broker returns its own public keys + * + * DELETE /api/register unlinks a server (requires server signature). + */ + +import { + getWorkspace, + activateWorkspace, + deactivateWorkspace, + hashAuthCode, +} from "../routing/registry.js"; +import { verify, canonicalizeOutbound } from "../crypto/verify.js"; +import { decodeBase64, encodeBase64 } from "../util/encoding.js"; +import type { Env } from "../index.js"; + +interface RegisterRequest { + workspace_id: string; + server_pubkey: string; + server_signing_pubkey: string; + server_callback_url: string; + auth_code: string; +} + +interface RegisterResponse { + ok: boolean; + broker_pubkey?: string; + broker_signing_pubkey?: string; + error?: string; +} + +/** + * Handle server registration. + */ +export async function handleRegister( + request: Request, + env: Env, +): Promise { + if (request.method === "DELETE") { + return handleUnregister(request, env); + } + + if (request.method !== "POST") { + return jsonResponse({ ok: false, error: "method not allowed" }, 405); + } + + let body: RegisterRequest; + try { + body = (await request.json()) as RegisterRequest; + } catch { + return jsonResponse({ ok: false, error: "invalid JSON" }, 400); + } + + // Validate required fields + if ( + !body.workspace_id || + !body.server_pubkey || + !body.server_signing_pubkey || + !body.server_callback_url || + !body.auth_code + ) { + return jsonResponse({ ok: false, error: "missing required fields" }, 400); + } + + // Validate callback URL + try { + const url = new URL(body.server_callback_url); + if (url.protocol !== "https:") { + return jsonResponse({ ok: false, error: "callback URL must use HTTPS" }, 400); + } + } catch { + return jsonResponse({ ok: false, error: "invalid callback URL" }, 400); + } + + // Look up workspace + const workspace = await getWorkspace(env.WORKSPACE_ROUTING, body.workspace_id); + if (!workspace) { + return jsonResponse({ ok: false, error: "workspace not found — complete OAuth install first" }, 404); + } + + // Verify auth code + const providedHash = await hashAuthCode(body.auth_code); + if (providedHash !== workspace.auth_code_hash) { + return jsonResponse({ ok: false, error: "invalid auth code" }, 403); + } + + // Activate workspace + const activated = await activateWorkspace( + env.WORKSPACE_ROUTING, + body.workspace_id, + body.server_callback_url, + body.server_pubkey, + body.server_signing_pubkey, + ); + + if (!activated) { + return jsonResponse({ ok: false, error: "failed to activate workspace" }, 500); + } + + // Return broker's public keys + const response: RegisterResponse = { + ok: true, + broker_pubkey: env.BROKER_PUBLIC_KEY, + broker_signing_pubkey: env.BROKER_SIGNING_PUBLIC_KEY, + }; + + return jsonResponse(response, 200); +} + +/** + * Handle server unregistration (DELETE /api/register). + * + * Requires the server to sign the request to prove identity. + */ +async function handleUnregister( + request: Request, + env: Env, +): Promise { + let body: { workspace_id: string; timestamp: number; signature: string }; + try { + body = (await request.json()) as typeof body; + } catch { + return jsonResponse({ ok: false, error: "invalid JSON" }, 400); + } + + if (!body.workspace_id || !body.timestamp || !body.signature) { + return jsonResponse({ ok: false, error: "missing required fields" }, 400); + } + + // Replay protection + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - body.timestamp) > 300) { + return jsonResponse({ ok: false, error: "timestamp too old" }, 400); + } + + // Look up workspace to get server's signing key + const workspace = await getWorkspace(env.WORKSPACE_ROUTING, body.workspace_id); + if (!workspace || workspace.status !== "active") { + return jsonResponse({ ok: false, error: "workspace not active" }, 404); + } + + // Verify server's signature + const canonical = canonicalizeOutbound( + body.workspace_id, + "unregister", + body.timestamp, + "", + ); + const serverSigningPubkey = decodeBase64(workspace.server_signing_pubkey); + const valid = verify(canonical, body.signature, serverSigningPubkey); + + if (!valid) { + return jsonResponse({ ok: false, error: "invalid signature" }, 403); + } + + await deactivateWorkspace(env.WORKSPACE_ROUTING, body.workspace_id); + return jsonResponse({ ok: true }, 200); +} + +function jsonResponse(data: unknown, status: number): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/slack-broker/src/api/send.ts b/slack-broker/src/api/send.ts new file mode 100644 index 0000000..f61c060 --- /dev/null +++ b/slack-broker/src/api/send.ts @@ -0,0 +1,216 @@ +/** + * Outbound message endpoint — POST /api/send + * + * Receives an encrypted message from a baudbot server, decrypts the body, + * posts it to Slack, then zeroes the plaintext from memory. + * + * Request format (structured encryption — Option A from spec): + * { + * "workspace_id": "T09192W1Z34", + * "action": "chat.postMessage" | "reactions.add" | "chat.update", + * "routing": { + * "channel": "C0A2G6TSDL6", + * "thread_ts": "1771464783.614839" // optional + * }, + * "encrypted_body": "", + * "nonce": "", + * "timestamp": 1771465000, + * "signature": "" + * } + * + * The broker: + * 1. Verifies the server's signature + * 2. Checks timestamp for replay protection + * 3. Decrypts the body using crypto_box_open + * 4. Posts to Slack using the appropriate API method + * 5. Zeroes plaintext from memory + */ + +import { boxDecrypt, zeroBytes } from "../crypto/box.js"; +import { verify, canonicalizeOutbound } from "../crypto/verify.js"; +import { decodeBase64, decodeUTF8 } from "../util/encoding.js"; +import { getWorkspace } from "../routing/registry.js"; +import { postMessage, addReaction, updateMessage } from "../slack/api.js"; +import type { Env } from "../index.js"; + +const FIVE_MINUTES = 5 * 60; +const VALID_ACTIONS = ["chat.postMessage", "reactions.add", "chat.update"] as const; +type SlackAction = (typeof VALID_ACTIONS)[number]; + +interface SendRequest { + workspace_id: string; + action: string; + routing: { + channel: string; + thread_ts?: string; + timestamp?: string; // for reactions.add and chat.update + emoji?: string; // for reactions.add + }; + encrypted_body: string; + nonce: string; + timestamp: number; + signature: string; +} + +/** + * Handle an outbound send request from a baudbot server. + */ +export async function handleSend( + request: Request, + env: Env, +): Promise { + if (request.method !== "POST") { + return jsonResponse({ ok: false, error: "method not allowed" }, 405); + } + + let body: SendRequest; + try { + body = (await request.json()) as SendRequest; + } catch { + return jsonResponse({ ok: false, error: "invalid JSON" }, 400); + } + + // Validate required fields + if ( + !body.workspace_id || + !body.action || + !body.routing?.channel || + !body.encrypted_body || + !body.nonce || + !body.timestamp || + !body.signature + ) { + return jsonResponse({ ok: false, error: "missing required fields" }, 400); + } + + // Validate action + if (!VALID_ACTIONS.includes(body.action as SlackAction)) { + return jsonResponse({ ok: false, error: `invalid action: ${body.action}` }, 400); + } + + // Replay protection + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - body.timestamp) > FIVE_MINUTES) { + return jsonResponse({ ok: false, error: "timestamp too old or too far in future" }, 400); + } + + // Look up workspace + const workspace = await getWorkspace(env.WORKSPACE_ROUTING, body.workspace_id); + if (!workspace || workspace.status !== "active") { + return jsonResponse({ ok: false, error: "workspace not active" }, 404); + } + + // Verify server's signature on the request + const canonical = canonicalizeOutbound( + body.workspace_id, + body.action, + body.timestamp, + body.encrypted_body, + ); + const serverSigningPubkey = decodeBase64(workspace.server_signing_pubkey); + const validSig = verify(canonical, body.signature, serverSigningPubkey); + + if (!validSig) { + return jsonResponse({ ok: false, error: "invalid signature" }, 403); + } + + // Decrypt the message body + let decryptedBytes: Uint8Array; + try { + const senderPubkey = decodeBase64(workspace.server_pubkey); + decryptedBytes = boxDecrypt( + body.encrypted_body, + body.nonce, + senderPubkey, + env.BROKER_PRIVATE_KEY_BYTES, + ); + } catch (err) { + const message = err instanceof Error ? err.message : "decryption failed"; + return jsonResponse({ ok: false, error: message }, 400); + } + + // Parse decrypted body + let decryptedBody: Record; + try { + decryptedBody = JSON.parse(decodeUTF8(decryptedBytes)) as Record; + } catch { + zeroBytes(decryptedBytes); + return jsonResponse({ ok: false, error: "decrypted body is not valid JSON" }, 400); + } + + // Execute the Slack API call + try { + const result = await executeSlackAction( + body.action as SlackAction, + workspace.bot_token, + body.routing, + decryptedBody, + ); + + // Zero the plaintext immediately after posting + zeroBytes(decryptedBytes); + + if (!result.ok) { + return jsonResponse({ ok: false, error: result.error ?? "slack api error" }, 502); + } + + return jsonResponse({ ok: true, ts: result.ts }, 200); + } catch (err) { + // Zero plaintext even on error + zeroBytes(decryptedBytes); + const message = err instanceof Error ? err.message : "unknown error"; + return jsonResponse({ ok: false, error: message }, 500); + } +} + +/** + * Dispatch to the appropriate Slack API method. + */ +async function executeSlackAction( + action: SlackAction, + botToken: string, + routing: SendRequest["routing"], + decryptedBody: Record, +): Promise<{ ok: boolean; ts?: string; error?: string }> { + switch (action) { + case "chat.postMessage": + return postMessage( + botToken, + routing.channel, + (decryptedBody.text as string) ?? "", + { + thread_ts: routing.thread_ts, + blocks: decryptedBody.blocks as unknown[] | undefined, + }, + ); + + case "reactions.add": + return addReaction( + botToken, + routing.channel, + routing.timestamp ?? "", + routing.emoji ?? (decryptedBody.emoji as string) ?? "", + ); + + case "chat.update": + return updateMessage( + botToken, + routing.channel, + routing.timestamp ?? "", + (decryptedBody.text as string) ?? "", + { + blocks: decryptedBody.blocks as unknown[] | undefined, + }, + ); + + default: + return { ok: false, error: `unsupported action: ${action}` }; + } +} + +function jsonResponse(data: unknown, status: number): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/slack-broker/src/crypto/box.ts b/slack-broker/src/crypto/box.ts new file mode 100644 index 0000000..e41a0ef --- /dev/null +++ b/slack-broker/src/crypto/box.ts @@ -0,0 +1,82 @@ +/** + * Authenticated encryption — used for outbound (server → Slack) path. + * + * The server encrypts the message body with crypto_box (server_sk, broker_pk). + * The broker decrypts transiently to post to Slack, then zeroes the plaintext. + * + * crypto_box provides: + * - Confidentiality (XSalsa20) + * - Integrity (Poly1305 MAC) + * - Authentication (sender identity verified via shared secret) + */ + +import nacl from "tweetnacl"; +import { decodeBase64, encodeBase64 } from "../util/encoding.js"; + +/** + * Encrypt with crypto_box (authenticated encryption). + * + * Used by the SERVER to encrypt outbound message bodies. + * Included here for testing and symmetry. + * + * @returns { ciphertext: base64, nonce: base64 } + */ +export function boxEncrypt( + plaintext: Uint8Array, + recipientPublicKey: Uint8Array, + senderSecretKey: Uint8Array, +): { ciphertext: string; nonce: string } { + const nonce = nacl.randomBytes(nacl.box.nonceLength); + const ciphertext = nacl.box(plaintext, nonce, recipientPublicKey, senderSecretKey); + + if (!ciphertext) { + throw new Error("boxEncrypt: encryption failed"); + } + + return { + ciphertext: encodeBase64(ciphertext), + nonce: encodeBase64(nonce), + }; +} + +/** + * Decrypt with crypto_box_open (authenticated decryption). + * + * Used by the BROKER to decrypt outbound message bodies from servers. + * The broker decrypts, posts to Slack, then callers must zero the result. + * + * @param ciphertextBase64 - base64-encoded ciphertext + * @param nonceBase64 - base64-encoded nonce + * @param senderPublicKey - the server's public key (authenticates sender) + * @param recipientSecretKey - the broker's secret key + * @returns decrypted plaintext bytes + */ +export function boxDecrypt( + ciphertextBase64: string, + nonceBase64: string, + senderPublicKey: Uint8Array, + recipientSecretKey: Uint8Array, +): Uint8Array { + const ciphertext = decodeBase64(ciphertextBase64); + const nonce = decodeBase64(nonceBase64); + + if (nonce.length !== nacl.box.nonceLength) { + throw new Error(`boxDecrypt: invalid nonce length (expected ${nacl.box.nonceLength}, got ${nonce.length})`); + } + + const plaintext = nacl.box.open(ciphertext, nonce, senderPublicKey, recipientSecretKey); + + if (!plaintext) { + throw new Error("boxDecrypt: decryption failed — invalid key, corrupted data, or wrong sender"); + } + + return plaintext; +} + +/** + * Zero out a Uint8Array to minimize plaintext residence in memory. + * Call this after posting the decrypted content to Slack. + */ +export function zeroBytes(arr: Uint8Array): void { + arr.fill(0); +} diff --git a/slack-broker/src/crypto/seal.ts b/slack-broker/src/crypto/seal.ts new file mode 100644 index 0000000..be6ab26 --- /dev/null +++ b/slack-broker/src/crypto/seal.ts @@ -0,0 +1,89 @@ +/** + * Sealed box encryption — used for inbound (Slack → server) path. + * + * The broker encrypts with the server's public key. Only the server's + * private key can decrypt. The broker CANNOT decrypt sealed boxes. + * + * Uses tweetnacl's box.keyPair + secretbox under the hood to implement + * the libsodium crypto_box_seal pattern: + * 1. Generate an ephemeral X25519 keypair + * 2. Compute shared secret: ECDH(ephemeral_sk, recipient_pk) + * 3. Derive nonce from ephemeral_pk + recipient_pk + * 4. Encrypt payload with crypto_box using the shared secret + * 5. Output: ephemeral_pk || ciphertext + */ + +import nacl from "tweetnacl"; +import { decodeBase64, encodeBase64 } from "../util/encoding.js"; + +/** Length of an X25519 public key in bytes. */ +const PUBLIC_KEY_BYTES = 32; + +/** + * Derive a nonce from the ephemeral public key and recipient public key. + * Uses the first 24 bytes of SHA-512(ephemeral_pk || recipient_pk). + */ +async function deriveNonce( + ephemeralPk: Uint8Array, + recipientPk: Uint8Array, +): Promise { + const input = new Uint8Array(PUBLIC_KEY_BYTES * 2); + input.set(ephemeralPk, 0); + input.set(recipientPk, PUBLIC_KEY_BYTES); + const hash = await crypto.subtle.digest("SHA-512", input); + return new Uint8Array(hash).slice(0, nacl.box.nonceLength); +} + +/** + * Encrypt a message using a sealed box (crypto_box_seal equivalent). + * + * Returns base64-encoded ciphertext: ephemeral_pk (32 bytes) || box output. + * Only the holder of `recipientPublicKey`'s corresponding private key can decrypt. + */ +export async function sealedBoxEncrypt( + plaintext: Uint8Array, + recipientPublicKey: Uint8Array, +): Promise { + const ephemeral = nacl.box.keyPair(); + const nonce = await deriveNonce(ephemeral.publicKey, recipientPublicKey); + const ciphertext = nacl.box(plaintext, nonce, recipientPublicKey, ephemeral.secretKey); + + if (!ciphertext) { + throw new Error("sealedBoxEncrypt: encryption failed"); + } + + // Output: ephemeral_pk || ciphertext + const sealed = new Uint8Array(PUBLIC_KEY_BYTES + ciphertext.length); + sealed.set(ephemeral.publicKey, 0); + sealed.set(ciphertext, PUBLIC_KEY_BYTES); + return encodeBase64(sealed); +} + +/** + * Decrypt a sealed box (crypto_box_seal_open equivalent). + * + * Used on the SERVER side (not in the broker for inbound messages). + * Included here for testing and for potential future use. + */ +export async function sealedBoxDecrypt( + sealedBase64: string, + recipientPublicKey: Uint8Array, + recipientSecretKey: Uint8Array, +): Promise { + const sealed = decodeBase64(sealedBase64); + + if (sealed.length < PUBLIC_KEY_BYTES + nacl.box.overheadLength) { + throw new Error("sealedBoxDecrypt: ciphertext too short"); + } + + const ephemeralPk = sealed.slice(0, PUBLIC_KEY_BYTES); + const ciphertext = sealed.slice(PUBLIC_KEY_BYTES); + const nonce = await deriveNonce(ephemeralPk, recipientPublicKey); + const plaintext = nacl.box.open(ciphertext, nonce, ephemeralPk, recipientSecretKey); + + if (!plaintext) { + throw new Error("sealedBoxDecrypt: decryption failed — invalid key or corrupted data"); + } + + return plaintext; +} diff --git a/slack-broker/src/crypto/verify.ts b/slack-broker/src/crypto/verify.ts new file mode 100644 index 0000000..073d5f3 --- /dev/null +++ b/slack-broker/src/crypto/verify.ts @@ -0,0 +1,86 @@ +/** + * Signature creation and verification for envelope authentication. + * + * - The broker signs outbound envelopes (forwarded events) so servers can + * verify they came from the broker. + * - Servers sign outbound requests so the broker can verify the sender. + * + * Uses Ed25519 (tweetnacl's sign module) for deterministic signatures. + */ + +import nacl from "tweetnacl"; +import { decodeBase64, encodeBase64 } from "../util/encoding.js"; + +/** + * Sign a message with an Ed25519 secret key. + * + * @param message - the bytes to sign + * @param secretKey - 64-byte Ed25519 secret key + * @returns base64-encoded detached signature + */ +export function sign(message: Uint8Array, secretKey: Uint8Array): string { + const signature = nacl.sign.detached(message, secretKey); + return encodeBase64(signature); +} + +/** + * Verify a detached Ed25519 signature. + * + * @param message - the original message bytes + * @param signatureBase64 - base64-encoded detached signature + * @param publicKey - 32-byte Ed25519 public key + * @returns true if signature is valid + */ +export function verify( + message: Uint8Array, + signatureBase64: string, + publicKey: Uint8Array, +): boolean { + try { + const signature = decodeBase64(signatureBase64); + return nacl.sign.detached.verify(message, signature, publicKey); + } catch { + return false; + } +} + +/** + * Generate an Ed25519 signing keypair. + * + * @returns { publicKey, secretKey } — both as Uint8Arrays + */ +export function generateSigningKeypair(): nacl.SignKeyPair { + return nacl.sign.keyPair(); +} + +/** + * Construct the canonical bytes for envelope signing. + * + * Canonicalizes the envelope fields into a deterministic byte string + * to prevent field-ordering or encoding ambiguities. + * + * Format: "workspace_id|timestamp|encrypted_payload_base64" + */ +export function canonicalizeEnvelope( + workspaceId: string, + timestamp: number, + encryptedPayload: string, +): Uint8Array { + const canonical = `${workspaceId}|${timestamp}|${encryptedPayload}`; + return new TextEncoder().encode(canonical); +} + +/** + * Construct the canonical bytes for outbound request signing. + * + * Format: "workspace_id|action|timestamp|encrypted_body_base64" + */ +export function canonicalizeOutbound( + workspaceId: string, + action: string, + timestamp: number, + encryptedBody: string, +): Uint8Array { + const canonical = `${workspaceId}|${action}|${timestamp}|${encryptedBody}`; + return new TextEncoder().encode(canonical); +} diff --git a/slack-broker/src/index.ts b/slack-broker/src/index.ts new file mode 100644 index 0000000..2ceec02 --- /dev/null +++ b/slack-broker/src/index.ts @@ -0,0 +1,141 @@ +/** + * Slack Broker — Cloudflare Worker entry point. + * + * Routes incoming requests to the appropriate handler: + * POST /slack/events — Slack Events API webhook + * GET /slack/oauth/install — Start OAuth install flow + * GET /slack/oauth/callback — Handle OAuth callback + * POST /api/register — Register a baudbot server + * DELETE /api/register — Unlink a baudbot server + * POST /api/send — Outbound: encrypted message → Slack + * GET /api/broker-pubkey — Get broker's public keys + * GET /health — Health check + */ + +import { handleSlackEvent } from "./slack/events.js"; +import { handleOAuthInstall, handleOAuthCallback } from "./slack/oauth.js"; +import { handleRegister } from "./api/register.js"; +import { handleSend } from "./api/send.js"; +import { decodeBase64, encodeBase64 } from "./util/encoding.js"; +import nacl from "tweetnacl"; + +/** + * Environment bindings available to the worker. + */ +export interface Env { + // KV namespaces + WORKSPACE_ROUTING: KVNamespace; + OAUTH_STATE: KVNamespace; + + // Secrets (set via `wrangler secret put`) + BROKER_PRIVATE_KEY: string; // Base64-encoded X25519 private key (32-byte seed) + SLACK_CLIENT_ID: string; + SLACK_CLIENT_SECRET: string; + SLACK_SIGNING_SECRET: string; + + // Derived at request time (not stored as secrets) + BROKER_PUBLIC_KEY: string; // Base64-encoded X25519 public key + BROKER_SIGNING_PUBLIC_KEY: string; // Base64-encoded Ed25519 public key + BROKER_PRIVATE_KEY_BYTES: Uint8Array; + BROKER_SIGNING_KEY: Uint8Array; // Ed25519 secret key for signing envelopes + + // Execution context (for waitUntil) + _ctx?: ExecutionContext; +} + +/** Cloudflare KV namespace interface. */ +interface KVNamespace { + get(key: string): Promise; + put(key: string, value: string, options?: { expirationTtl?: number }): Promise; + delete(key: string): Promise; +} + +export default { + async fetch( + request: Request, + rawEnv: Record, + ctx: ExecutionContext, + ): Promise { + const url = new URL(request.url); + const path = url.pathname; + + // Derive keys from the secret seed + const privateKeyBase64 = rawEnv.BROKER_PRIVATE_KEY as string; + if (!privateKeyBase64) { + return new Response("server misconfigured: missing BROKER_PRIVATE_KEY", { status: 500 }); + } + + const seed = decodeBase64(privateKeyBase64); + const boxKeypair = nacl.box.keyPair.fromSecretKey(seed); + const signKeypair = nacl.sign.keyPair.fromSeed(seed); + + // Build the full Env object + const env: Env = { + WORKSPACE_ROUTING: rawEnv.WORKSPACE_ROUTING as unknown as KVNamespace, + OAUTH_STATE: rawEnv.OAUTH_STATE as unknown as KVNamespace, + BROKER_PRIVATE_KEY: privateKeyBase64, + SLACK_CLIENT_ID: rawEnv.SLACK_CLIENT_ID as string, + SLACK_CLIENT_SECRET: rawEnv.SLACK_CLIENT_SECRET as string, + SLACK_SIGNING_SECRET: rawEnv.SLACK_SIGNING_SECRET as string, + BROKER_PUBLIC_KEY: encodeBase64(boxKeypair.publicKey), + BROKER_SIGNING_PUBLIC_KEY: encodeBase64(signKeypair.publicKey), + BROKER_PRIVATE_KEY_BYTES: boxKeypair.secretKey, + BROKER_SIGNING_KEY: signKeypair.secretKey, + _ctx: ctx, + }; + + // Route requests + try { + // Health check + if (path === "/health" && request.method === "GET") { + return new Response(JSON.stringify({ ok: true, service: "slack-broker" }), { + headers: { "Content-Type": "application/json" }, + }); + } + + // Broker public key endpoint + if (path === "/api/broker-pubkey" && request.method === "GET") { + return new Response( + JSON.stringify({ + ok: true, + broker_pubkey: env.BROKER_PUBLIC_KEY, + broker_signing_pubkey: env.BROKER_SIGNING_PUBLIC_KEY, + }), + { headers: { "Content-Type": "application/json" } }, + ); + } + + // Slack Events API + if (path === "/slack/events" && request.method === "POST") { + return handleSlackEvent(request, env); + } + + // OAuth install flow + if (path === "/slack/oauth/install" && request.method === "GET") { + return handleOAuthInstall(request, env); + } + + // OAuth callback + if (path === "/slack/oauth/callback" && request.method === "GET") { + return handleOAuthCallback(request, env); + } + + // Server registration + if (path === "/api/register") { + return handleRegister(request, env); + } + + // Outbound message sending + if (path === "/api/send" && request.method === "POST") { + return handleSend(request, env); + } + + // 404 for everything else + return new Response("not found", { status: 404 }); + } catch (err) { + // Never leak internal errors — log routing metadata only + console.error(`[${request.method}] ${path} — unhandled error:`, err instanceof Error ? err.message : "unknown"); + return new Response("internal error", { status: 500 }); + } + }, +}; diff --git a/slack-broker/src/routing/forward.ts b/slack-broker/src/routing/forward.ts new file mode 100644 index 0000000..7eb83db --- /dev/null +++ b/slack-broker/src/routing/forward.ts @@ -0,0 +1,101 @@ +/** + * Encrypt a Slack event and forward it to the registered server. + * + * Flow: + * 1. Look up workspace → server_pubkey, server_url + * 2. Encrypt the event payload with sealed box (server_pubkey) + * 3. Sign the envelope with the broker's signing key + * 4. POST the envelope to the server's callback URL + * + * If the server is unreachable, the event is dropped (no queuing). + * The broker logs routing metadata (workspace_id, timestamp) but NEVER message content. + */ + +import { sealedBoxEncrypt } from "../crypto/seal.js"; +import { sign, canonicalizeEnvelope } from "../crypto/verify.js"; +import { decodeBase64, encodeUTF8 } from "../util/encoding.js"; +import type { WorkspaceRecord } from "./registry.js"; + +export interface ForwardEnvelope { + workspace_id: string; + encrypted: string; + timestamp: number; + signature: string; +} + +export interface ForwardResult { + ok: boolean; + status?: number; + error?: string; +} + +/** + * Encrypt and forward a Slack event to the registered server. + * + * @param event - the raw Slack event payload (JSON object) + * @param workspace - the workspace routing record + * @param brokerSigningKey - the broker's Ed25519 secret key for signing envelopes + * @returns result indicating success/failure + */ +export async function forwardEvent( + event: unknown, + workspace: WorkspaceRecord, + brokerSigningKey: Uint8Array, +): Promise { + if (workspace.status !== "active") { + return { ok: false, error: "workspace not active" }; + } + + if (!workspace.server_url || !workspace.server_pubkey) { + return { ok: false, error: "workspace missing server configuration" }; + } + + // Serialize and encrypt + const plaintext = encodeUTF8(JSON.stringify(event)); + const serverPubkey = decodeBase64(workspace.server_pubkey); + const encrypted = await sealedBoxEncrypt(plaintext, serverPubkey); + + // Build and sign envelope + const timestamp = Math.floor(Date.now() / 1000); + const canonical = canonicalizeEnvelope(workspace.workspace_id, timestamp, encrypted); + const signature = sign(canonical, brokerSigningKey); + + const envelope: ForwardEnvelope = { + workspace_id: workspace.workspace_id, + encrypted, + timestamp, + signature, + }; + + // Forward to server — fire and forget with timeout + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10_000); + + const response = await fetch(workspace.server_url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Broker-Signature": signature, + "X-Broker-Timestamp": String(timestamp), + }, + body: JSON.stringify(envelope), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return { + ok: false, + status: response.status, + error: `server responded with ${response.status}`, + }; + } + + return { ok: true, status: response.status }; + } catch (err) { + const message = err instanceof Error ? err.message : "unknown error"; + return { ok: false, error: `forward failed: ${message}` }; + } +} diff --git a/slack-broker/src/routing/registry.ts b/slack-broker/src/routing/registry.ts new file mode 100644 index 0000000..869fe82 --- /dev/null +++ b/slack-broker/src/routing/registry.ts @@ -0,0 +1,141 @@ +/** + * KV-backed workspace routing registry. + * + * Maps workspace_id → { server_url, server_pubkey, bot_token, status }. + * Stored in the WORKSPACE_ROUTING KV namespace. + * + * Status lifecycle: pending → active → (optionally) inactive + * - pending: OAuth complete, no server linked yet + * - active: server registered and verified + * - inactive: server unlinked or heartbeat timed out + */ + +import { encodeBase64 } from "../util/encoding.js"; + +export type WorkspaceStatus = "pending" | "active" | "inactive"; + +export interface WorkspaceRecord { + workspace_id: string; + team_name: string; + server_url: string; + /** Base64-encoded X25519 public key for sealing inbound messages. */ + server_pubkey: string; + /** Base64-encoded Ed25519 public key for verifying server signatures. */ + server_signing_pubkey: string; + /** Encrypted bot token (encrypted at rest in KV). */ + bot_token: string; + status: WorkspaceStatus; + /** ISO 8601 timestamp of last registration or heartbeat. */ + updated_at: string; + /** Auth code hash — used during registration to verify workspace ownership. */ + auth_code_hash: string; +} + +/** KV binding type for Cloudflare Workers. */ +export interface KVNamespace { + get(key: string, options?: { type?: string }): Promise; + put(key: string, value: string, options?: { expirationTtl?: number }): Promise; + delete(key: string): Promise; +} + +/** + * Get a workspace record from KV. + */ +export async function getWorkspace( + kv: KVNamespace, + workspaceId: string, +): Promise { + const raw = await kv.get(`workspace:${workspaceId}`); + if (!raw) return null; + return JSON.parse(raw) as WorkspaceRecord; +} + +/** + * Store a workspace record in KV. + */ +export async function putWorkspace( + kv: KVNamespace, + record: WorkspaceRecord, +): Promise { + await kv.put(`workspace:${record.workspace_id}`, JSON.stringify(record)); +} + +/** + * Create a pending workspace record after OAuth. + * The bot_token is stored as-is (Cloudflare KV is encrypted at rest). + */ +export async function createPendingWorkspace( + kv: KVNamespace, + workspaceId: string, + teamName: string, + botToken: string, + authCodeHash: string, +): Promise { + const record: WorkspaceRecord = { + workspace_id: workspaceId, + team_name: teamName, + server_url: "", + server_pubkey: "", + server_signing_pubkey: "", + bot_token: botToken, + status: "pending", + updated_at: new Date().toISOString(), + auth_code_hash: authCodeHash, + }; + await putWorkspace(kv, record); + return record; +} + +/** + * Activate a workspace by registering a server. + */ +export async function activateWorkspace( + kv: KVNamespace, + workspaceId: string, + serverUrl: string, + serverPubkey: string, + serverSigningPubkey: string, +): Promise { + const record = await getWorkspace(kv, workspaceId); + if (!record) return null; + + record.server_url = serverUrl; + record.server_pubkey = serverPubkey; + record.server_signing_pubkey = serverSigningPubkey; + record.status = "active"; + record.updated_at = new Date().toISOString(); + await putWorkspace(kv, record); + return record; +} + +/** + * Deactivate a workspace (unlink server). + */ +export async function deactivateWorkspace( + kv: KVNamespace, + workspaceId: string, +): Promise { + const record = await getWorkspace(kv, workspaceId); + if (!record) return false; + + record.status = "inactive"; + record.server_url = ""; + record.server_pubkey = ""; + record.server_signing_pubkey = ""; + record.updated_at = new Date().toISOString(); + await putWorkspace(kv, record); + return true; +} + +/** + * Hash an auth code for storage (SHA-256, hex-encoded). + * We never store raw auth codes. + */ +export async function hashAuthCode(authCode: string): Promise { + const encoded = new TextEncoder().encode(authCode); + const hash = await crypto.subtle.digest("SHA-256", encoded); + const bytes = new Uint8Array(hash); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/slack-broker/src/slack/api.ts b/slack-broker/src/slack/api.ts new file mode 100644 index 0000000..cf19a80 --- /dev/null +++ b/slack-broker/src/slack/api.ts @@ -0,0 +1,103 @@ +/** + * Slack Web API helpers. + * + * Used by the broker to post messages, add reactions, and update messages. + * These are the minimum API calls needed for the outbound path. + * + * SECURITY: Message content is only held transiently in memory. + * After calling Slack's API, callers should zero any plaintext buffers. + */ + +export interface SlackApiResult { + ok: boolean; + ts?: string; + error?: string; +} + +/** + * Post a message to a Slack channel. + */ +export async function postMessage( + botToken: string, + channel: string, + text: string, + options?: { + thread_ts?: string; + blocks?: unknown[]; + }, +): Promise { + const body: Record = { + channel, + text, + }; + + if (options?.thread_ts) body.thread_ts = options.thread_ts; + if (options?.blocks) body.blocks = options.blocks; + + return callSlackApi(botToken, "chat.postMessage", body); +} + +/** + * Add a reaction to a message. + */ +export async function addReaction( + botToken: string, + channel: string, + timestamp: string, + emoji: string, +): Promise { + return callSlackApi(botToken, "reactions.add", { + channel, + timestamp, + name: emoji, + }); +} + +/** + * Update an existing message. + */ +export async function updateMessage( + botToken: string, + channel: string, + timestamp: string, + text: string, + options?: { + blocks?: unknown[]; + }, +): Promise { + const body: Record = { + channel, + ts: timestamp, + text, + }; + + if (options?.blocks) body.blocks = options.blocks; + + return callSlackApi(botToken, "chat.update", body); +} + +/** + * Generic Slack Web API caller. + */ +async function callSlackApi( + botToken: string, + method: string, + body: Record, +): Promise { + try { + const response = await fetch(`https://slack.com/api/${method}`, { + method: "POST", + headers: { + "Authorization": `Bearer ${botToken}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify(body), + }); + + const data = (await response.json()) as SlackApiResult; + return data; + } catch (err) { + const message = err instanceof Error ? err.message : "unknown error"; + return { ok: false, error: `slack api call failed: ${message}` }; + } +} diff --git a/slack-broker/src/slack/events.ts b/slack-broker/src/slack/events.ts new file mode 100644 index 0000000..f69163f --- /dev/null +++ b/slack-broker/src/slack/events.ts @@ -0,0 +1,161 @@ +/** + * Slack Events API handler. + * + * Responsibilities: + * 1. Verify Slack request signatures (HMAC-SHA256) + * 2. Handle url_verification challenge (Slack app setup) + * 3. Parse event payloads and dispatch to the forwarding pipeline + * + * Replay protection: reject events with timestamps older than 5 minutes. + */ + +import { getWorkspace } from "../routing/registry.js"; +import { forwardEvent } from "../routing/forward.js"; +import type { Env } from "../index.js"; + +const FIVE_MINUTES = 5 * 60; + +/** + * Verify a Slack request signature. + * + * Slack signs requests with HMAC-SHA256 using the signing secret. + * See: https://api.slack.com/authentication/verifying-requests-from-slack + * + * @param signingSecret - the app's signing secret + * @param timestamp - X-Slack-Request-Timestamp header + * @param body - raw request body string + * @param signature - X-Slack-Signature header (v0=...) + * @returns true if the signature is valid and timestamp is fresh + */ +export async function verifySlackSignature( + signingSecret: string, + timestamp: string, + body: string, + signature: string, +): Promise { + // Replay protection: reject timestamps older than 5 minutes + const ts = parseInt(timestamp, 10); + if (isNaN(ts)) return false; + + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - ts) > FIVE_MINUTES) return false; + + // Compute expected signature + const sigBasestring = `v0:${timestamp}:${body}`; + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(signingSecret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const mac = await crypto.subtle.sign("HMAC", key, encoder.encode(sigBasestring)); + const expected = `v0=${Array.from(new Uint8Array(mac)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("")}`; + + // Constant-time comparison + if (expected.length !== signature.length) return false; + let mismatch = 0; + for (let i = 0; i < expected.length; i++) { + mismatch |= expected.charCodeAt(i) ^ signature.charCodeAt(i); + } + return mismatch === 0; +} + +interface SlackChallenge { + type: "url_verification"; + challenge: string; + token: string; +} + +interface SlackEventCallback { + type: "event_callback"; + team_id: string; + event: { + type: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +type SlackPayload = SlackChallenge | SlackEventCallback; + +/** + * Handle an incoming Slack Events API request. + */ +export async function handleSlackEvent( + request: Request, + env: Env, +): Promise { + // Read and verify signature + const body = await request.text(); + const timestamp = request.headers.get("X-Slack-Request-Timestamp") ?? ""; + const signature = request.headers.get("X-Slack-Signature") ?? ""; + + const valid = await verifySlackSignature( + env.SLACK_SIGNING_SECRET, + timestamp, + body, + signature, + ); + + if (!valid) { + return new Response("invalid signature", { status: 401 }); + } + + // Parse payload + let payload: SlackPayload; + try { + payload = JSON.parse(body) as SlackPayload; + } catch { + return new Response("invalid JSON", { status: 400 }); + } + + // Handle url_verification challenge + if (payload.type === "url_verification") { + return new Response(JSON.stringify({ challenge: (payload as SlackChallenge).challenge }), { + headers: { "Content-Type": "application/json" }, + }); + } + + // Handle event callbacks + if (payload.type === "event_callback") { + const eventPayload = payload as SlackEventCallback; + const workspaceId = eventPayload.team_id; + + if (!workspaceId) { + return new Response("missing team_id", { status: 400 }); + } + + // Look up workspace routing + const workspace = await getWorkspace(env.WORKSPACE_ROUTING, workspaceId); + + if (!workspace || workspace.status !== "active") { + // ACK the event but don't forward — no active server + return new Response("ok", { status: 200 }); + } + + // Forward asynchronously — ACK Slack immediately (3-second deadline) + // Use waitUntil to continue processing after responding + const brokerSigningKey = env.BROKER_SIGNING_KEY; + + if (brokerSigningKey) { + const forwardPromise = forwardEvent( + eventPayload, + workspace, + brokerSigningKey, + ); + + // If we have a ctx for waitUntil, use it; otherwise fire-and-forget + if (env._ctx) { + env._ctx.waitUntil(forwardPromise); + } + } + + return new Response("ok", { status: 200 }); + } + + return new Response("unknown event type", { status: 400 }); +} diff --git a/slack-broker/src/slack/oauth.ts b/slack-broker/src/slack/oauth.ts new file mode 100644 index 0000000..09b66b2 --- /dev/null +++ b/slack-broker/src/slack/oauth.ts @@ -0,0 +1,167 @@ +/** + * Slack OAuth install flow. + * + * Two endpoints: + * 1. GET /slack/oauth/install — redirect user to Slack's authorization page + * 2. GET /slack/oauth/callback — handle the OAuth callback, store bot token + * + * After OAuth completes, the workspace is in "pending" status until a + * baudbot server registers via the /api/register endpoint. + * + * The auth_code is generated during OAuth and must be presented during + * server registration to prove workspace ownership. + */ + +import { createPendingWorkspace, hashAuthCode } from "../routing/registry.js"; +import type { Env } from "../index.js"; + +/** Required OAuth scopes for the Prime app. */ +const BOT_SCOPES = [ + "app_mentions:read", + "channels:history", + "chat:write", + "groups:history", + "im:history", + "reactions:write", + "users:read", +].join(","); + +/** + * Generate a cryptographically random string for OAuth state and auth codes. + */ +function generateRandomString(length: number): string { + const bytes = crypto.getRandomValues(new Uint8Array(length)); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * Redirect to Slack's OAuth authorization page. + */ +export async function handleOAuthInstall( + request: Request, + env: Env, +): Promise { + const state = generateRandomString(32); + + // Store state with 10-minute expiry + await env.OAUTH_STATE.put(`oauth_state:${state}`, "valid", { + expirationTtl: 600, + }); + + const url = new URL("https://slack.com/oauth/v2/authorize"); + url.searchParams.set("client_id", env.SLACK_CLIENT_ID); + url.searchParams.set("scope", BOT_SCOPES); + url.searchParams.set("state", state); + + // Derive redirect URI from the current request's origin + const requestUrl = new URL(request.url); + const redirectUri = `${requestUrl.origin}/slack/oauth/callback`; + url.searchParams.set("redirect_uri", redirectUri); + + return Response.redirect(url.toString(), 302); +} + +interface SlackOAuthResponse { + ok: boolean; + access_token?: string; + token_type?: string; + team?: { id: string; name: string }; + bot_user_id?: string; + error?: string; +} + +/** + * Handle the OAuth callback from Slack. + * + * Exchanges the code for a bot token, creates a pending workspace record, + * and returns the auth_code that the user needs for server registration. + */ +export async function handleOAuthCallback( + request: Request, + env: Env, +): Promise { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + + // User denied access + if (error) { + return new Response( + `

Installation cancelled

${escapeHtml(error)}

`, + { status: 200, headers: { "Content-Type": "text/html" } }, + ); + } + + if (!code || !state) { + return new Response("missing code or state", { status: 400 }); + } + + // Verify state parameter + const storedState = await env.OAUTH_STATE.get(`oauth_state:${state}`); + if (!storedState) { + return new Response("invalid or expired state", { status: 400 }); + } + // Delete state to prevent reuse + await env.OAUTH_STATE.delete(`oauth_state:${state}`); + + // Exchange code for token + const redirectUri = `${url.origin}/slack/oauth/callback`; + const tokenResponse = await fetch("https://slack.com/api/oauth.v2.access", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: env.SLACK_CLIENT_ID, + client_secret: env.SLACK_CLIENT_SECRET, + code, + redirect_uri: redirectUri, + }), + }); + + const tokenData = (await tokenResponse.json()) as SlackOAuthResponse; + + if (!tokenData.ok || !tokenData.access_token || !tokenData.team) { + return new Response( + `

Installation failed

${escapeHtml(tokenData.error ?? "unknown error")}

`, + { status: 200, headers: { "Content-Type": "text/html" } }, + ); + } + + // Generate auth code for server registration + const authCode = generateRandomString(32); + const authCodeHashed = await hashAuthCode(authCode); + + // Store workspace with pending status + await createPendingWorkspace( + env.WORKSPACE_ROUTING, + tokenData.team.id, + tokenData.team.name, + tokenData.access_token, + authCodeHashed, + ); + + // Return success page with auth code + return new Response( + ` + +

✅ Baudbot installed in ${escapeHtml(tokenData.team.name)}

+

Your workspace ID: ${escapeHtml(tokenData.team.id)}

+

Your auth code (save this — you'll need it during server setup):

+
${escapeHtml(authCode)}
+

Run baudbot setup --slack-broker on your server and enter this code when prompted.

+ +`, + { status: 200, headers: { "Content-Type": "text/html" } }, + ); +} + +/** Minimal HTML escaping. */ +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/slack-broker/src/util/encoding.ts b/slack-broker/src/util/encoding.ts new file mode 100644 index 0000000..ca21849 --- /dev/null +++ b/slack-broker/src/util/encoding.ts @@ -0,0 +1,43 @@ +/** + * Base64 encoding/decoding utilities. + * + * Uses standard base64 (not URL-safe) for consistency with tweetnacl-util + * and common Slack/crypto conventions. + */ + +/** + * Encode a Uint8Array to a base64 string. + */ +export function encodeBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]!); + } + return btoa(binary); +} + +/** + * Decode a base64 string to a Uint8Array. + */ +export function decodeBase64(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * Encode a UTF-8 string to Uint8Array. + */ +export function encodeUTF8(str: string): Uint8Array { + return new TextEncoder().encode(str); +} + +/** + * Decode a Uint8Array to a UTF-8 string. + */ +export function decodeUTF8(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} diff --git a/slack-broker/test/crypto.test.ts b/slack-broker/test/crypto.test.ts new file mode 100644 index 0000000..92bdd8b --- /dev/null +++ b/slack-broker/test/crypto.test.ts @@ -0,0 +1,269 @@ +/** + * Unit tests for crypto modules: sealed boxes, authenticated encryption, signatures. + */ + +import { describe, it, expect } from "vitest"; +import nacl from "tweetnacl"; +import { sealedBoxEncrypt, sealedBoxDecrypt } from "../src/crypto/seal.js"; +import { boxEncrypt, boxDecrypt, zeroBytes } from "../src/crypto/box.js"; +import { + sign, + verify, + generateSigningKeypair, + canonicalizeEnvelope, + canonicalizeOutbound, +} from "../src/crypto/verify.js"; +import { encodeBase64, decodeBase64, encodeUTF8, decodeUTF8 } from "../src/util/encoding.js"; + +describe("sealed box (crypto_box_seal)", () => { + it("encrypts and decrypts a message", async () => { + const recipientKeypair = nacl.box.keyPair(); + const plaintext = encodeUTF8("hello from Slack"); + + const sealed = await sealedBoxEncrypt(plaintext, recipientKeypair.publicKey); + expect(sealed).toBeTruthy(); + expect(typeof sealed).toBe("string"); + + const decrypted = await sealedBoxDecrypt( + sealed, + recipientKeypair.publicKey, + recipientKeypair.secretKey, + ); + expect(decodeUTF8(decrypted)).toBe("hello from Slack"); + }); + + it("different encryptions produce different ciphertexts (ephemeral keys)", async () => { + const recipientKeypair = nacl.box.keyPair(); + const plaintext = encodeUTF8("same message"); + + const sealed1 = await sealedBoxEncrypt(plaintext, recipientKeypair.publicKey); + const sealed2 = await sealedBoxEncrypt(plaintext, recipientKeypair.publicKey); + + // Ephemeral keypairs mean different ciphertexts + expect(sealed1).not.toBe(sealed2); + + // Both decrypt to the same plaintext + const d1 = await sealedBoxDecrypt(sealed1, recipientKeypair.publicKey, recipientKeypair.secretKey); + const d2 = await sealedBoxDecrypt(sealed2, recipientKeypair.publicKey, recipientKeypair.secretKey); + expect(decodeUTF8(d1)).toBe("same message"); + expect(decodeUTF8(d2)).toBe("same message"); + }); + + it("fails to decrypt with wrong private key", async () => { + const recipientKeypair = nacl.box.keyPair(); + const wrongKeypair = nacl.box.keyPair(); + const plaintext = encodeUTF8("secret message"); + + const sealed = await sealedBoxEncrypt(plaintext, recipientKeypair.publicKey); + + await expect( + sealedBoxDecrypt(sealed, wrongKeypair.publicKey, wrongKeypair.secretKey), + ).rejects.toThrow("decryption failed"); + }); + + it("fails on truncated ciphertext", async () => { + const recipientKeypair = nacl.box.keyPair(); + + await expect( + sealedBoxDecrypt("AAAA", recipientKeypair.publicKey, recipientKeypair.secretKey), + ).rejects.toThrow("ciphertext too short"); + }); + + it("handles empty plaintext", async () => { + const recipientKeypair = nacl.box.keyPair(); + const plaintext = new Uint8Array(0); + + const sealed = await sealedBoxEncrypt(plaintext, recipientKeypair.publicKey); + const decrypted = await sealedBoxDecrypt( + sealed, + recipientKeypair.publicKey, + recipientKeypair.secretKey, + ); + expect(decrypted.length).toBe(0); + }); + + it("handles large payloads", async () => { + const recipientKeypair = nacl.box.keyPair(); + const plaintext = new Uint8Array(100_000); + // Fill in chunks — crypto.getRandomValues has a 65536-byte limit + for (let i = 0; i < plaintext.length; i += 65536) { + const chunk = plaintext.subarray(i, Math.min(i + 65536, plaintext.length)); + crypto.getRandomValues(chunk); + } + + const sealed = await sealedBoxEncrypt(plaintext, recipientKeypair.publicKey); + const decrypted = await sealedBoxDecrypt( + sealed, + recipientKeypair.publicKey, + recipientKeypair.secretKey, + ); + expect(decrypted).toEqual(plaintext); + }); +}); + +describe("authenticated box (crypto_box)", () => { + it("encrypts and decrypts a message", () => { + const sender = nacl.box.keyPair(); + const recipient = nacl.box.keyPair(); + const plaintext = encodeUTF8('{"text": "hello"}'); + + const { ciphertext, nonce } = boxEncrypt(plaintext, recipient.publicKey, sender.secretKey); + expect(ciphertext).toBeTruthy(); + expect(nonce).toBeTruthy(); + + const decrypted = boxDecrypt(ciphertext, nonce, sender.publicKey, recipient.secretKey); + expect(decodeUTF8(decrypted)).toBe('{"text": "hello"}'); + }); + + it("verifies sender identity (fails with wrong sender key)", () => { + const sender = nacl.box.keyPair(); + const recipient = nacl.box.keyPair(); + const imposter = nacl.box.keyPair(); + const plaintext = encodeUTF8("authentic message"); + + const { ciphertext, nonce } = boxEncrypt(plaintext, recipient.publicKey, sender.secretKey); + + // Try to decrypt claiming a different sender + expect(() => + boxDecrypt(ciphertext, nonce, imposter.publicKey, recipient.secretKey), + ).toThrow("decryption failed"); + }); + + it("fails with wrong recipient key", () => { + const sender = nacl.box.keyPair(); + const recipient = nacl.box.keyPair(); + const wrongRecipient = nacl.box.keyPair(); + const plaintext = encodeUTF8("secret"); + + const { ciphertext, nonce } = boxEncrypt(plaintext, recipient.publicKey, sender.secretKey); + + expect(() => + boxDecrypt(ciphertext, nonce, sender.publicKey, wrongRecipient.secretKey), + ).toThrow("decryption failed"); + }); + + it("fails with invalid nonce length", () => { + const sender = nacl.box.keyPair(); + const recipient = nacl.box.keyPair(); + const plaintext = encodeUTF8("test"); + + const { ciphertext } = boxEncrypt(plaintext, recipient.publicKey, sender.secretKey); + const badNonce = encodeBase64(new Uint8Array(8)); // Wrong length + + expect(() => + boxDecrypt(ciphertext, badNonce, sender.publicKey, recipient.secretKey), + ).toThrow("invalid nonce length"); + }); + + it("each encryption uses a unique nonce", () => { + const sender = nacl.box.keyPair(); + const recipient = nacl.box.keyPair(); + const plaintext = encodeUTF8("same"); + + const r1 = boxEncrypt(plaintext, recipient.publicKey, sender.secretKey); + const r2 = boxEncrypt(plaintext, recipient.publicKey, sender.secretKey); + + expect(r1.nonce).not.toBe(r2.nonce); + expect(r1.ciphertext).not.toBe(r2.ciphertext); + }); +}); + +describe("zeroBytes", () => { + it("zeroes a buffer", () => { + const buf = new Uint8Array([1, 2, 3, 4, 5]); + zeroBytes(buf); + expect(buf).toEqual(new Uint8Array([0, 0, 0, 0, 0])); + }); +}); + +describe("signatures (Ed25519)", () => { + it("sign and verify round-trip", () => { + const keypair = generateSigningKeypair(); + const message = encodeUTF8("important envelope"); + + const sig = sign(message, keypair.secretKey); + expect(verify(message, sig, keypair.publicKey)).toBe(true); + }); + + it("rejects tampered message", () => { + const keypair = generateSigningKeypair(); + const message = encodeUTF8("original"); + + const sig = sign(message, keypair.secretKey); + const tampered = encodeUTF8("tampered"); + expect(verify(tampered, sig, keypair.publicKey)).toBe(false); + }); + + it("rejects wrong public key", () => { + const keypair1 = generateSigningKeypair(); + const keypair2 = generateSigningKeypair(); + const message = encodeUTF8("test"); + + const sig = sign(message, keypair1.secretKey); + expect(verify(message, sig, keypair2.publicKey)).toBe(false); + }); + + it("rejects invalid signature format", () => { + const keypair = generateSigningKeypair(); + const message = encodeUTF8("test"); + + expect(verify(message, "not-valid-base64!!!", keypair.publicKey)).toBe(false); + }); + + it("rejects truncated signature", () => { + const keypair = generateSigningKeypair(); + const message = encodeUTF8("test"); + + const sig = sign(message, keypair.secretKey); + const truncated = sig.slice(0, 10); + expect(verify(message, truncated, keypair.publicKey)).toBe(false); + }); +}); + +describe("canonicalize", () => { + it("envelope canonicalization is deterministic", () => { + const a = canonicalizeEnvelope("T123", 1000, "encrypted_data"); + const b = canonicalizeEnvelope("T123", 1000, "encrypted_data"); + expect(a).toEqual(b); + }); + + it("envelope canonicalization differs with different inputs", () => { + const a = canonicalizeEnvelope("T123", 1000, "data1"); + const b = canonicalizeEnvelope("T123", 1000, "data2"); + expect(a).not.toEqual(b); + }); + + it("outbound canonicalization is deterministic", () => { + const a = canonicalizeOutbound("T123", "chat.postMessage", 1000, "body"); + const b = canonicalizeOutbound("T123", "chat.postMessage", 1000, "body"); + expect(a).toEqual(b); + }); + + it("outbound canonicalization includes action", () => { + const a = canonicalizeOutbound("T123", "chat.postMessage", 1000, "body"); + const b = canonicalizeOutbound("T123", "reactions.add", 1000, "body"); + expect(a).not.toEqual(b); + }); +}); + +describe("encoding utilities", () => { + it("base64 round-trip", () => { + const original = new Uint8Array([0, 1, 127, 128, 255]); + const encoded = encodeBase64(original); + const decoded = decodeBase64(encoded); + expect(decoded).toEqual(original); + }); + + it("utf8 round-trip", () => { + const original = "Hello 🌍 world — test"; + const encoded = encodeUTF8(original); + const decoded = decodeUTF8(encoded); + expect(decoded).toBe(original); + }); + + it("base64 of empty array", () => { + const encoded = encodeBase64(new Uint8Array(0)); + const decoded = decodeBase64(encoded); + expect(decoded.length).toBe(0); + }); +}); diff --git a/slack-broker/test/integration.test.ts b/slack-broker/test/integration.test.ts new file mode 100644 index 0000000..38d077c --- /dev/null +++ b/slack-broker/test/integration.test.ts @@ -0,0 +1,314 @@ +/** + * End-to-end message flow integration tests. + * + * Tests the complete paths: + * 1. Inbound: Slack event → verify signature → encrypt → forward to server + * 2. Outbound: Server sends encrypted reply → broker decrypts → posts to Slack + * 3. Registration: OAuth → register server → activate workspace + * 4. Slack signature verification edge cases + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import nacl from "tweetnacl"; +import { verifySlackSignature } from "../src/slack/events.js"; +import { boxEncrypt, boxDecrypt } from "../src/crypto/box.js"; +import { sealedBoxDecrypt } from "../src/crypto/seal.js"; +import { sign, canonicalizeOutbound, canonicalizeEnvelope, verify } from "../src/crypto/verify.js"; +import { encodeBase64, decodeBase64, encodeUTF8, decodeUTF8 } from "../src/util/encoding.js"; +import { + createPendingWorkspace, + activateWorkspace, + getWorkspace, + hashAuthCode, + type KVNamespace, +} from "../src/routing/registry.js"; + +/** In-memory KV mock. */ +function createMockKV(): KVNamespace { + const store = new Map(); + return { + async get(key: string) { + return store.get(key) ?? null; + }, + async put(key: string, value: string) { + store.set(key, value); + }, + async delete(key: string) { + store.delete(key); + }, + }; +} + +/** + * Generate a valid Slack signature for testing. + */ +async function makeSlackSignature( + signingSecret: string, + timestamp: string, + body: string, +): Promise { + const sigBasestring = `v0:${timestamp}:${body}`; + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(signingSecret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const mac = await crypto.subtle.sign("HMAC", key, encoder.encode(sigBasestring)); + return `v0=${Array.from(new Uint8Array(mac)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("")}`; +} + +describe("Slack signature verification", () => { + const signingSecret = "test_signing_secret_1234567890"; + + it("accepts a valid signature", async () => { + const timestamp = String(Math.floor(Date.now() / 1000)); + const body = '{"type":"url_verification","challenge":"abc"}'; + const sig = await makeSlackSignature(signingSecret, timestamp, body); + + const valid = await verifySlackSignature(signingSecret, timestamp, body, sig); + expect(valid).toBe(true); + }); + + it("rejects an invalid signature", async () => { + const timestamp = String(Math.floor(Date.now() / 1000)); + const body = '{"type":"url_verification"}'; + + const valid = await verifySlackSignature(signingSecret, timestamp, body, "v0=deadbeef"); + expect(valid).toBe(false); + }); + + it("rejects a stale timestamp (>5 minutes old)", async () => { + const staleTimestamp = String(Math.floor(Date.now() / 1000) - 400); + const body = '{"type":"event_callback"}'; + const sig = await makeSlackSignature(signingSecret, staleTimestamp, body); + + const valid = await verifySlackSignature(signingSecret, staleTimestamp, body, sig); + expect(valid).toBe(false); + }); + + it("rejects a future timestamp (>5 minutes ahead)", async () => { + const futureTimestamp = String(Math.floor(Date.now() / 1000) + 400); + const body = '{"type":"event_callback"}'; + const sig = await makeSlackSignature(signingSecret, futureTimestamp, body); + + const valid = await verifySlackSignature(signingSecret, futureTimestamp, body, sig); + expect(valid).toBe(false); + }); + + it("rejects non-numeric timestamp", async () => { + const valid = await verifySlackSignature(signingSecret, "not-a-number", "{}", "v0=abc"); + expect(valid).toBe(false); + }); + + it("rejects wrong signing secret", async () => { + const timestamp = String(Math.floor(Date.now() / 1000)); + const body = '{"type":"url_verification"}'; + const sig = await makeSlackSignature("wrong_secret", timestamp, body); + + const valid = await verifySlackSignature(signingSecret, timestamp, body, sig); + expect(valid).toBe(false); + }); + + it("rejects signature with wrong length", async () => { + const timestamp = String(Math.floor(Date.now() / 1000)); + const body = "test"; + const valid = await verifySlackSignature(signingSecret, timestamp, body, "v0=ab"); + expect(valid).toBe(false); + }); +}); + +describe("end-to-end inbound flow", () => { + it("encrypts an event that only the server can decrypt", async () => { + // Setup: broker and server keypairs + const brokerSignKeypair = nacl.sign.keyPair(); + const serverBoxKeypair = nacl.box.keyPair(); + + // Simulate: broker encrypts a Slack event for the server + const slackEvent = { + type: "event_callback", + team_id: "T123", + event: { type: "app_mention", text: "<@U123> hello", channel: "C456", ts: "1234.5678" }, + }; + + const { sealedBoxEncrypt } = await import("../src/crypto/seal.js"); + const plaintext = encodeUTF8(JSON.stringify(slackEvent)); + const encrypted = await sealedBoxEncrypt(plaintext, serverBoxKeypair.publicKey); + + // Sign the envelope + const timestamp = Math.floor(Date.now() / 1000); + const canonical = canonicalizeEnvelope("T123", timestamp, encrypted); + const signature = sign(canonical, brokerSignKeypair.secretKey); + + // Server side: verify signature and decrypt + const isValid = verify(canonical, signature, brokerSignKeypair.publicKey); + expect(isValid).toBe(true); + + const decrypted = await sealedBoxDecrypt( + encrypted, + serverBoxKeypair.publicKey, + serverBoxKeypair.secretKey, + ); + + const recoveredEvent = JSON.parse(decodeUTF8(decrypted)); + expect(recoveredEvent.team_id).toBe("T123"); + expect(recoveredEvent.event.text).toBe("<@U123> hello"); + }); + + it("broker cannot decrypt its own sealed box output", async () => { + const brokerBoxKeypair = nacl.box.keyPair(); + const serverBoxKeypair = nacl.box.keyPair(); + + const { sealedBoxEncrypt } = await import("../src/crypto/seal.js"); + const plaintext = encodeUTF8("sensitive message"); + const encrypted = await sealedBoxEncrypt(plaintext, serverBoxKeypair.publicKey); + + // Broker tries to decrypt with its OWN keys — should fail + await expect( + sealedBoxDecrypt(encrypted, brokerBoxKeypair.publicKey, brokerBoxKeypair.secretKey), + ).rejects.toThrow("decryption failed"); + }); +}); + +describe("end-to-end outbound flow", () => { + it("server encrypts a reply that the broker can decrypt", () => { + // Setup + const brokerBoxKeypair = nacl.box.keyPair(); + const serverBoxKeypair = nacl.box.keyPair(); + const serverSignKeypair = nacl.sign.keyPair(); + + // Server encrypts the message body + const messageBody = JSON.stringify({ text: "Here's your answer!", blocks: [] }); + const { ciphertext, nonce } = boxEncrypt( + encodeUTF8(messageBody), + brokerBoxKeypair.publicKey, + serverBoxKeypair.secretKey, + ); + + // Server signs the request + const timestamp = Math.floor(Date.now() / 1000); + const canonical = canonicalizeOutbound("T123", "chat.postMessage", timestamp, ciphertext); + const signature = sign(canonical, serverSignKeypair.secretKey); + + // Broker side: verify signature + const isValid = verify(canonical, signature, serverSignKeypair.publicKey); + expect(isValid).toBe(true); + + // Broker decrypts the body + const decryptedBytes = boxDecrypt( + ciphertext, + nonce, + serverBoxKeypair.publicKey, + brokerBoxKeypair.secretKey, + ); + + const recovered = JSON.parse(decodeUTF8(decryptedBytes)); + expect(recovered.text).toBe("Here's your answer!"); + }); + + it("third party cannot forge a server message", () => { + const brokerBoxKeypair = nacl.box.keyPair(); + const serverBoxKeypair = nacl.box.keyPair(); + const serverSignKeypair = nacl.sign.keyPair(); + const attackerSignKeypair = nacl.sign.keyPair(); + + // Attacker tries to send a message signed with their own key + const body = JSON.stringify({ text: "malicious" }); + const { ciphertext, nonce } = boxEncrypt( + encodeUTF8(body), + brokerBoxKeypair.publicKey, + serverBoxKeypair.secretKey, + ); + + const timestamp = Math.floor(Date.now() / 1000); + const canonical = canonicalizeOutbound("T123", "chat.postMessage", timestamp, ciphertext); + + // Attacker signs with their key + const attackerSig = sign(canonical, attackerSignKeypair.secretKey); + + // Broker verifies against the REAL server's signing key — should fail + const isValid = verify(canonical, attackerSig, serverSignKeypair.publicKey); + expect(isValid).toBe(false); + }); +}); + +describe("end-to-end registration flow", () => { + let kv: KVNamespace; + + beforeEach(() => { + kv = createMockKV(); + }); + + it("full OAuth → register → activate cycle", async () => { + // 1. OAuth completes — create pending workspace + const authCode = "secret-auth-code-12345"; + const authCodeHashed = await hashAuthCode(authCode); + + await createPendingWorkspace(kv, "T123", "Test Team", "xoxb-fake-token", authCodeHashed); + + // Verify pending state + let ws = await getWorkspace(kv, "T123"); + expect(ws!.status).toBe("pending"); + expect(ws!.server_url).toBe(""); + + // 2. Server generates keys and registers + const serverBoxKeypair = nacl.box.keyPair(); + const serverSignKeypair = nacl.sign.keyPair(); + + // Verify auth code matches + const providedHash = await hashAuthCode(authCode); + expect(providedHash).toBe(ws!.auth_code_hash); + + // 3. Activate + const activated = await activateWorkspace( + kv, + "T123", + "https://my-server.example.com/broker/inbound", + encodeBase64(serverBoxKeypair.publicKey), + encodeBase64(serverSignKeypair.publicKey), + ); + + expect(activated!.status).toBe("active"); + expect(activated!.server_url).toBe("https://my-server.example.com/broker/inbound"); + + // 4. Verify the stored keys can be used for crypto + ws = await getWorkspace(kv, "T123"); + const storedPubkey = decodeBase64(ws!.server_pubkey); + expect(storedPubkey).toEqual(serverBoxKeypair.publicKey); + }); + + it("rejects registration with wrong auth code", async () => { + const authCode = "correct-code"; + const authCodeHashed = await hashAuthCode(authCode); + + await createPendingWorkspace(kv, "T123", "Test Team", "xoxb-fake", authCodeHashed); + + const wrongHash = await hashAuthCode("wrong-code"); + const ws = await getWorkspace(kv, "T123"); + expect(wrongHash).not.toBe(ws!.auth_code_hash); + }); +}); + +describe("replay protection", () => { + it("signature with old timestamp fails verification at application level", () => { + // Signatures themselves don't expire, but the broker checks timestamps + // before calling verify(). This test documents the canonicalization + // includes the timestamp, so replaying with a different timestamp + // will produce a different canonical form and fail verification. + const keypair = nacl.sign.keyPair(); + const timestamp1 = 1000; + const timestamp2 = 2000; + + const canonical1 = canonicalizeEnvelope("T123", timestamp1, "encrypted_data"); + const canonical2 = canonicalizeEnvelope("T123", timestamp2, "encrypted_data"); + + const sig1 = sign(canonical1, keypair.secretKey); + + // Signature from timestamp1 doesn't verify against canonical2 + expect(verify(canonical2, sig1, keypair.publicKey)).toBe(false); + }); +}); diff --git a/slack-broker/test/routing.test.ts b/slack-broker/test/routing.test.ts new file mode 100644 index 0000000..67b9399 --- /dev/null +++ b/slack-broker/test/routing.test.ts @@ -0,0 +1,251 @@ +/** + * Unit tests for registry (KV-backed workspace routing) and forwarding logic. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + getWorkspace, + putWorkspace, + createPendingWorkspace, + activateWorkspace, + deactivateWorkspace, + hashAuthCode, + type WorkspaceRecord, + type KVNamespace, +} from "../src/routing/registry.js"; +import { forwardEvent, type ForwardResult } from "../src/routing/forward.js"; +import nacl from "tweetnacl"; +import { encodeBase64 } from "../src/util/encoding.js"; + +/** In-memory KV mock. */ +function createMockKV(): KVNamespace { + const store = new Map(); + return { + async get(key: string) { + return store.get(key) ?? null; + }, + async put(key: string, value: string) { + store.set(key, value); + }, + async delete(key: string) { + store.delete(key); + }, + }; +} + +describe("registry", () => { + let kv: KVNamespace; + + beforeEach(() => { + kv = createMockKV(); + }); + + it("returns null for unknown workspace", async () => { + const result = await getWorkspace(kv, "T_UNKNOWN"); + expect(result).toBeNull(); + }); + + it("creates a pending workspace", async () => { + const record = await createPendingWorkspace(kv, "T123", "Test Team", "xoxb-token", "hash123"); + + expect(record.workspace_id).toBe("T123"); + expect(record.team_name).toBe("Test Team"); + expect(record.bot_token).toBe("xoxb-token"); + expect(record.status).toBe("pending"); + expect(record.auth_code_hash).toBe("hash123"); + expect(record.server_url).toBe(""); + expect(record.server_pubkey).toBe(""); + + // Verify it's stored in KV + const fetched = await getWorkspace(kv, "T123"); + expect(fetched).toEqual(record); + }); + + it("activates a workspace with server details", async () => { + await createPendingWorkspace(kv, "T123", "Test Team", "xoxb-token", "hash123"); + + const activated = await activateWorkspace( + kv, + "T123", + "https://server.example.com/broker/inbound", + "server_pubkey_base64", + "server_signing_pubkey_base64", + ); + + expect(activated).not.toBeNull(); + expect(activated!.status).toBe("active"); + expect(activated!.server_url).toBe("https://server.example.com/broker/inbound"); + expect(activated!.server_pubkey).toBe("server_pubkey_base64"); + expect(activated!.server_signing_pubkey).toBe("server_signing_pubkey_base64"); + }); + + it("returns null when activating non-existent workspace", async () => { + const result = await activateWorkspace(kv, "T_NONE", "url", "pk", "spk"); + expect(result).toBeNull(); + }); + + it("deactivates a workspace", async () => { + await createPendingWorkspace(kv, "T123", "Test Team", "xoxb-token", "hash123"); + await activateWorkspace(kv, "T123", "https://server.example.com", "pk", "spk"); + + const result = await deactivateWorkspace(kv, "T123"); + expect(result).toBe(true); + + const workspace = await getWorkspace(kv, "T123"); + expect(workspace!.status).toBe("inactive"); + expect(workspace!.server_url).toBe(""); + expect(workspace!.server_pubkey).toBe(""); + }); + + it("returns false when deactivating non-existent workspace", async () => { + const result = await deactivateWorkspace(kv, "T_NONE"); + expect(result).toBe(false); + }); + + it("put and get workspace round-trip", async () => { + const record: WorkspaceRecord = { + workspace_id: "T999", + team_name: "Round Trip", + server_url: "https://example.com", + server_pubkey: "pk", + server_signing_pubkey: "spk", + bot_token: "xoxb-test", + status: "active", + updated_at: new Date().toISOString(), + auth_code_hash: "abc", + }; + + await putWorkspace(kv, record); + const fetched = await getWorkspace(kv, "T999"); + expect(fetched).toEqual(record); + }); +}); + +describe("hashAuthCode", () => { + it("produces a hex string", async () => { + const hash = await hashAuthCode("test-code"); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it("is deterministic", async () => { + const h1 = await hashAuthCode("same-code"); + const h2 = await hashAuthCode("same-code"); + expect(h1).toBe(h2); + }); + + it("differs for different inputs", async () => { + const h1 = await hashAuthCode("code-a"); + const h2 = await hashAuthCode("code-b"); + expect(h1).not.toBe(h2); + }); +}); + +describe("forwardEvent", () => { + const brokerSignKeypair = nacl.sign.keyPair(); + + function makeActiveWorkspace(overrides?: Partial): WorkspaceRecord { + const serverKeypair = nacl.box.keyPair(); + return { + workspace_id: "T123", + team_name: "Test", + server_url: "https://server.example.com/broker/inbound", + server_pubkey: encodeBase64(serverKeypair.publicKey), + server_signing_pubkey: "", + bot_token: "xoxb-test", + status: "active", + updated_at: new Date().toISOString(), + auth_code_hash: "hash", + ...overrides, + }; + } + + it("rejects forwarding to non-active workspace", async () => { + const workspace = makeActiveWorkspace({ status: "pending" }); + const result = await forwardEvent({}, workspace, brokerSignKeypair.secretKey); + expect(result.ok).toBe(false); + expect(result.error).toContain("not active"); + }); + + it("rejects workspace with missing server_url", async () => { + const workspace = makeActiveWorkspace({ server_url: "" }); + const result = await forwardEvent({}, workspace, brokerSignKeypair.secretKey); + expect(result.ok).toBe(false); + expect(result.error).toContain("missing server configuration"); + }); + + it("rejects workspace with missing server_pubkey", async () => { + const workspace = makeActiveWorkspace({ server_pubkey: "" }); + const result = await forwardEvent({}, workspace, brokerSignKeypair.secretKey); + expect(result.ok).toBe(false); + expect(result.error).toContain("missing server configuration"); + }); + + it("forwards an event to a server (mocked fetch)", async () => { + const workspace = makeActiveWorkspace(); + const event = { type: "event_callback", event: { type: "message", text: "hi" } }; + + // Mock global fetch + const fetchSpy = vi.fn().mockResolvedValue(new Response("ok", { status: 200 })); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchSpy; + + try { + const result = await forwardEvent(event, workspace, brokerSignKeypair.secretKey); + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + + // Verify fetch was called with the right URL + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, options] = fetchSpy.mock.calls[0]!; + expect(url).toBe(workspace.server_url); + expect(options.method).toBe("POST"); + + // Verify envelope structure + const body = JSON.parse(options.body); + expect(body.workspace_id).toBe("T123"); + expect(body.encrypted).toBeTruthy(); + expect(body.timestamp).toBeTypeOf("number"); + expect(body.signature).toBeTruthy(); + + // Verify headers + expect(options.headers["Content-Type"]).toBe("application/json"); + expect(options.headers["X-Broker-Signature"]).toBeTruthy(); + expect(options.headers["X-Broker-Timestamp"]).toBeTruthy(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("reports server error status", async () => { + const workspace = makeActiveWorkspace(); + + const fetchSpy = vi.fn().mockResolvedValue(new Response("bad", { status: 503 })); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchSpy; + + try { + const result = await forwardEvent({}, workspace, brokerSignKeypair.secretKey); + expect(result.ok).toBe(false); + expect(result.status).toBe(503); + expect(result.error).toContain("503"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("handles network errors gracefully", async () => { + const workspace = makeActiveWorkspace(); + + const fetchSpy = vi.fn().mockRejectedValue(new Error("connection refused")); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchSpy; + + try { + const result = await forwardEvent({}, workspace, brokerSignKeypair.secretKey); + expect(result.ok).toBe(false); + expect(result.error).toContain("connection refused"); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/slack-broker/tsconfig.json b/slack-broker/tsconfig.json new file mode 100644 index 0000000..a7f4941 --- /dev/null +++ b/slack-broker/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/slack-broker/vitest.config.ts b/slack-broker/vitest.config.ts new file mode 100644 index 0000000..570df33 --- /dev/null +++ b/slack-broker/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["test/**/*.test.ts"], + }, +}); diff --git a/slack-broker/wrangler.toml b/slack-broker/wrangler.toml new file mode 100644 index 0000000..a647329 --- /dev/null +++ b/slack-broker/wrangler.toml @@ -0,0 +1,32 @@ +name = "slack-broker" +main = "src/index.ts" +compatibility_date = "2024-12-30" + +# KV Namespaces — create with: +# wrangler kv namespace create WORKSPACE_ROUTING +# wrangler kv namespace create OAUTH_STATE +[[kv_namespaces]] +binding = "WORKSPACE_ROUTING" +id = "PLACEHOLDER_WORKSPACE_ROUTING_ID" + +[[kv_namespaces]] +binding = "OAUTH_STATE" +id = "PLACEHOLDER_OAUTH_STATE_ID" + +# Secrets — set with: +# wrangler secret put BROKER_PRIVATE_KEY +# wrangler secret put SLACK_CLIENT_ID +# wrangler secret put SLACK_CLIENT_SECRET +# wrangler secret put SLACK_SIGNING_SECRET + +# Dev overrides +[env.dev] +name = "slack-broker-dev" + +[[env.dev.kv_namespaces]] +binding = "WORKSPACE_ROUTING" +id = "PLACEHOLDER_DEV_WORKSPACE_ROUTING_ID" + +[[env.dev.kv_namespaces]] +binding = "OAUTH_STATE" +id = "PLACEHOLDER_DEV_OAUTH_STATE_ID" From 7159b8f866d31e35165b502e52a11a9faeeed2c1 Mon Sep 17 00:00:00 2001 From: Baudbot Date: Wed, 18 Feb 2026 21:16:37 -0500 Subject: [PATCH 2/3] broker: remove unused tweetnacl-util dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback — encoding utilities are implemented in src/util/encoding.ts, tweetnacl-util was never imported. --- slack-broker/package-lock.json | 9 +-------- slack-broker/package.json | 3 +-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/slack-broker/package-lock.json b/slack-broker/package-lock.json index 831631f..f99b959 100644 --- a/slack-broker/package-lock.json +++ b/slack-broker/package-lock.json @@ -8,8 +8,7 @@ "name": "slack-broker", "version": "0.1.0", "dependencies": { - "tweetnacl": "^1.0.3", - "tweetnacl-util": "^0.15.1" + "tweetnacl": "^1.0.3" }, "devDependencies": { "@cloudflare/workers-types": "^4.20241230.0", @@ -2268,12 +2267,6 @@ "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", "license": "Unlicense" }, - "node_modules/tweetnacl-util": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", - "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==", - "license": "Unlicense" - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/slack-broker/package.json b/slack-broker/package.json index f5ce975..28a3bdc 100644 --- a/slack-broker/package.json +++ b/slack-broker/package.json @@ -11,8 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "tweetnacl": "^1.0.3", - "tweetnacl-util": "^0.15.1" + "tweetnacl": "^1.0.3" }, "devDependencies": { "@cloudflare/workers-types": "^4.20241230.0", From 52bf2d7696f9cb577886eb84c8b98ab00bdbe7aa Mon Sep 17 00:00:00 2001 From: Baudbot Date: Wed, 18 Feb 2026 21:36:31 -0500 Subject: [PATCH 3/3] =?UTF-8?q?security:=20fix=209=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20auth=20code=20reuse,=20OAuth=20hijack,=20crypto,=20?= =?UTF-8?q?token=20encryption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical fixes: 1. Auth code reuse: clear auth_code_hash after activation, reject re-registration of already-active workspaces (409 Conflict) 2. Re-OAuth overwrites active workspace: check workspace status in OAuth callback, reject re-install when server is active 3. Sealed box nonce: switch from SHA-512 to libsodium-wrappers-sumo with native crypto_box_seal (BLAKE2B nonce, spec-compliant) Important fixes: 4. Pipe delimiter injection: validate workspace_id matches /^T[A-Z0-9]+$/ 5. Unsalted hash: switch hashAuthCode from SHA-256 to HMAC-SHA256 keyed with BROKER_PRIVATE_KEY 6. Bot token plaintext in KV: encrypt with nacl.secretbox using key derived from BROKER_PRIVATE_KEY, decrypt on read 7. HTTPS enforcement: add protocol check in forwardEvent() 8. Rate limiting: add TODO comment noting Phase 3 requirement 9. zeroBytes docs: clarify best-effort cleanup, JS string limitation Tests: 54 → 64 (10 new tests covering all security changes) --- slack-broker/README.md | 8 +- slack-broker/package-lock.json | 16 ++++ slack-broker/package.json | 1 + slack-broker/src/api/register.ts | 20 ++++- slack-broker/src/api/send.ts | 7 +- slack-broker/src/crypto/box.ts | 9 ++- slack-broker/src/crypto/seal.ts | 69 ++++++---------- slack-broker/src/index.ts | 4 + slack-broker/src/routing/forward.ts | 10 +++ slack-broker/src/routing/registry.ts | 80 ++++++++++++++++--- slack-broker/src/slack/oauth.ts | 23 +++++- slack-broker/test/integration.test.ts | 28 +++++-- slack-broker/test/routing.test.ts | 108 +++++++++++++++++++++++--- 13 files changed, 298 insertions(+), 85 deletions(-) diff --git a/slack-broker/README.md b/slack-broker/README.md index dcf453f..694240b 100644 --- a/slack-broker/README.md +++ b/slack-broker/README.md @@ -113,7 +113,7 @@ Supported actions: `chat.postMessage`, `reactions.add`, `chat.update`. | Primitive | Use | Library | |-----------|-----|---------| -| `crypto_box_seal` (X25519 + XSalsa20-Poly1305) | Inbound: Slack → server | tweetnacl | +| `crypto_box_seal` (X25519 + XSalsa20-Poly1305) | Inbound: Slack → server | libsodium-wrappers-sumo | | `crypto_box` (X25519 + XSalsa20-Poly1305) | Outbound: server → Slack | tweetnacl | | Ed25519 | Envelope signatures | tweetnacl | | HMAC-SHA256 | Slack request verification | Web Crypto API | @@ -133,13 +133,15 @@ Supported actions: `chat.postMessage`, `reactions.add`, `chat.update`. - ✅ Server authenticates broker (broker signs envelopes) - ✅ Broker authenticates server (server signs outbound requests) - ✅ Replay protection (timestamps + nonces on all messages) -- ✅ Auth code verification for server registration +- ✅ Auth code verification for server registration (one-time use, HMAC-SHA256) +- ✅ Bot tokens encrypted at rest in KV (nacl.secretbox) - ❌ Perfect forward secrecy (would need session keys — future enhancement) +- ❌ Rate limiting (Phase 3 — pre-production requirement) ### What the Broker Can See - Routing metadata: workspace_id, channel, thread_ts, timestamps -- Outbound message content: **transiently** (decrypted in memory to post to Slack, then zeroed) +- Outbound message content: **transiently** (decrypted in memory to post to Slack, then best-effort zeroed — JS strings from JSON.parse cannot be deterministically zeroed, only the underlying Uint8Array buffer is cleared) ### What the Broker Cannot See diff --git a/slack-broker/package-lock.json b/slack-broker/package-lock.json index f99b959..d74baf3 100644 --- a/slack-broker/package-lock.json +++ b/slack-broker/package-lock.json @@ -8,6 +8,7 @@ "name": "slack-broker", "version": "0.1.0", "dependencies": { + "libsodium-wrappers-sumo": "^0.8.2", "tweetnacl": "^1.0.3" }, "devDependencies": { @@ -1804,6 +1805,21 @@ "license": "MIT", "optional": true }, + "node_modules/libsodium-sumo": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.2.tgz", + "integrity": "sha512-uMgnjphJ717jLN+jFG1HUgNrK/gOVVfaO1DGZ1Ig/fKLKLVhvaH/sM1I1v784JFvmkJDaczDpi7xSYC4Jvdo1Q==", + "license": "ISC" + }, + "node_modules/libsodium-wrappers-sumo": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.2.tgz", + "integrity": "sha512-wd1xAY++Kr6VMikSaa4EPRAHJmFvNlGWiiwU3Jh3GR1zRYF3/I3vy/wYsr4k3LVsNzwb9sqfEQ4LdVQ6zEebyQ==", + "license": "ISC", + "dependencies": { + "libsodium-sumo": "^0.8.0" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", diff --git a/slack-broker/package.json b/slack-broker/package.json index 28a3bdc..b41fe0a 100644 --- a/slack-broker/package.json +++ b/slack-broker/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "libsodium-wrappers-sumo": "^0.8.2", "tweetnacl": "^1.0.3" }, "devDependencies": { diff --git a/slack-broker/src/api/register.ts b/slack-broker/src/api/register.ts index d68c06b..d0cbe2f 100644 --- a/slack-broker/src/api/register.ts +++ b/slack-broker/src/api/register.ts @@ -73,6 +73,12 @@ export async function handleRegister( return jsonResponse({ ok: false, error: "missing required fields" }, 400); } + // Validate workspace_id matches Slack team ID format to prevent + // pipe-delimiter injection in canonicalized signatures. + if (!/^T[A-Z0-9]+$/.test(body.workspace_id)) { + return jsonResponse({ ok: false, error: "invalid workspace_id format" }, 400); + } + // Validate callback URL try { const url = new URL(body.server_callback_url); @@ -89,8 +95,18 @@ export async function handleRegister( return jsonResponse({ ok: false, error: "workspace not found — complete OAuth install first" }, 404); } - // Verify auth code - const providedHash = await hashAuthCode(body.auth_code); + // Reject re-registration of already-active workspaces. + // The current server must unregister first (DELETE /api/register). + if (workspace.status === "active") { + return jsonResponse({ ok: false, error: "workspace already active — unregister the current server first" }, 409); + } + + // Verify auth code (must not be empty — cleared after first successful registration) + if (!workspace.auth_code_hash) { + return jsonResponse({ ok: false, error: "auth code already consumed — re-install the Slack app to generate a new one" }, 403); + } + + const providedHash = await hashAuthCode(body.auth_code, env.BROKER_PRIVATE_KEY); if (providedHash !== workspace.auth_code_hash) { return jsonResponse({ ok: false, error: "invalid auth code" }, 403); } diff --git a/slack-broker/src/api/send.ts b/slack-broker/src/api/send.ts index f61c060..ab9db6e 100644 --- a/slack-broker/src/api/send.ts +++ b/slack-broker/src/api/send.ts @@ -29,7 +29,7 @@ import { boxDecrypt, zeroBytes } from "../crypto/box.js"; import { verify, canonicalizeOutbound } from "../crypto/verify.js"; import { decodeBase64, decodeUTF8 } from "../util/encoding.js"; -import { getWorkspace } from "../routing/registry.js"; +import { getWorkspace, decryptBotToken } from "../routing/registry.js"; import { postMessage, addReaction, updateMessage } from "../slack/api.js"; import type { Env } from "../index.js"; @@ -140,9 +140,12 @@ export async function handleSend( // Execute the Slack API call try { + // Decrypt the bot token (encrypted at rest in KV) + const botToken = decryptBotToken(workspace.bot_token, env.BROKER_PRIVATE_KEY); + const result = await executeSlackAction( body.action as SlackAction, - workspace.bot_token, + botToken, body.routing, decryptedBody, ); diff --git a/slack-broker/src/crypto/box.ts b/slack-broker/src/crypto/box.ts index e41a0ef..13b6db6 100644 --- a/slack-broker/src/crypto/box.ts +++ b/slack-broker/src/crypto/box.ts @@ -74,8 +74,13 @@ export function boxDecrypt( } /** - * Zero out a Uint8Array to minimize plaintext residence in memory. - * Call this after posting the decrypted content to Slack. + * Best-effort memory cleanup — zeroes a Uint8Array to reduce plaintext + * residence in memory. Call this after posting the decrypted content to Slack. + * + * NOTE: This only zeroes the Uint8Array buffer. JS strings derived from the + * buffer (e.g. via TextDecoder or JSON.parse) are immutable and cannot be + * deterministically zeroed — they remain in memory until garbage collected. + * This is a limitation of the JS runtime, not a bug. */ export function zeroBytes(arr: Uint8Array): void { arr.fill(0); diff --git a/slack-broker/src/crypto/seal.ts b/slack-broker/src/crypto/seal.ts index be6ab26..f9e9f95 100644 --- a/slack-broker/src/crypto/seal.ts +++ b/slack-broker/src/crypto/seal.ts @@ -4,63 +4,44 @@ * The broker encrypts with the server's public key. Only the server's * private key can decrypt. The broker CANNOT decrypt sealed boxes. * - * Uses tweetnacl's box.keyPair + secretbox under the hood to implement - * the libsodium crypto_box_seal pattern: - * 1. Generate an ephemeral X25519 keypair - * 2. Compute shared secret: ECDH(ephemeral_sk, recipient_pk) - * 3. Derive nonce from ephemeral_pk + recipient_pk - * 4. Encrypt payload with crypto_box using the shared secret - * 5. Output: ephemeral_pk || ciphertext + * Uses libsodium's native crypto_box_seal / crypto_box_seal_open for + * interoperability with standard libsodium implementations on the server. + * The nonce is derived using BLAKE2B(ephemeral_pk || recipient_pk) as + * per the libsodium spec. */ -import nacl from "tweetnacl"; -import { decodeBase64, encodeBase64 } from "../util/encoding.js"; +import _sodium from "libsodium-wrappers-sumo"; +import { encodeBase64, decodeBase64 } from "../util/encoding.js"; /** Length of an X25519 public key in bytes. */ const PUBLIC_KEY_BYTES = 32; -/** - * Derive a nonce from the ephemeral public key and recipient public key. - * Uses the first 24 bytes of SHA-512(ephemeral_pk || recipient_pk). - */ -async function deriveNonce( - ephemeralPk: Uint8Array, - recipientPk: Uint8Array, -): Promise { - const input = new Uint8Array(PUBLIC_KEY_BYTES * 2); - input.set(ephemeralPk, 0); - input.set(recipientPk, PUBLIC_KEY_BYTES); - const hash = await crypto.subtle.digest("SHA-512", input); - return new Uint8Array(hash).slice(0, nacl.box.nonceLength); +/** Ensure libsodium is initialized before use. */ +async function sodium(): Promise { + await _sodium.ready; + return _sodium; } /** - * Encrypt a message using a sealed box (crypto_box_seal equivalent). + * Encrypt a message using a sealed box (crypto_box_seal). * - * Returns base64-encoded ciphertext: ephemeral_pk (32 bytes) || box output. + * Returns base64-encoded ciphertext (ephemeral_pk || box output). * Only the holder of `recipientPublicKey`'s corresponding private key can decrypt. + * + * Uses libsodium's native implementation with BLAKE2B nonce derivation + * for interoperability with standard libsodium on the server side. */ export async function sealedBoxEncrypt( plaintext: Uint8Array, recipientPublicKey: Uint8Array, ): Promise { - const ephemeral = nacl.box.keyPair(); - const nonce = await deriveNonce(ephemeral.publicKey, recipientPublicKey); - const ciphertext = nacl.box(plaintext, nonce, recipientPublicKey, ephemeral.secretKey); - - if (!ciphertext) { - throw new Error("sealedBoxEncrypt: encryption failed"); - } - - // Output: ephemeral_pk || ciphertext - const sealed = new Uint8Array(PUBLIC_KEY_BYTES + ciphertext.length); - sealed.set(ephemeral.publicKey, 0); - sealed.set(ciphertext, PUBLIC_KEY_BYTES); + const s = await sodium(); + const sealed = s.crypto_box_seal(plaintext, recipientPublicKey); return encodeBase64(sealed); } /** - * Decrypt a sealed box (crypto_box_seal_open equivalent). + * Decrypt a sealed box (crypto_box_seal_open). * * Used on the SERVER side (not in the broker for inbound messages). * Included here for testing and for potential future use. @@ -70,20 +51,16 @@ export async function sealedBoxDecrypt( recipientPublicKey: Uint8Array, recipientSecretKey: Uint8Array, ): Promise { + const s = await sodium(); const sealed = decodeBase64(sealedBase64); - if (sealed.length < PUBLIC_KEY_BYTES + nacl.box.overheadLength) { + if (sealed.length < PUBLIC_KEY_BYTES + s.crypto_box_MACBYTES) { throw new Error("sealedBoxDecrypt: ciphertext too short"); } - const ephemeralPk = sealed.slice(0, PUBLIC_KEY_BYTES); - const ciphertext = sealed.slice(PUBLIC_KEY_BYTES); - const nonce = await deriveNonce(ephemeralPk, recipientPublicKey); - const plaintext = nacl.box.open(ciphertext, nonce, ephemeralPk, recipientSecretKey); - - if (!plaintext) { + try { + return s.crypto_box_seal_open(sealed, recipientPublicKey, recipientSecretKey); + } catch { throw new Error("sealedBoxDecrypt: decryption failed — invalid key or corrupted data"); } - - return plaintext; } diff --git a/slack-broker/src/index.ts b/slack-broker/src/index.ts index 2ceec02..6030ced 100644 --- a/slack-broker/src/index.ts +++ b/slack-broker/src/index.ts @@ -84,6 +84,10 @@ export default { _ctx: ctx, }; + // TODO(Phase 3): Add rate limiting before production deployment. + // Per-workspace and per-IP limits on /api/send, /api/register, and /slack/events. + // Cloudflare Rate Limiting rules or a KV-based token bucket are both viable. + // Route requests try { // Health check diff --git a/slack-broker/src/routing/forward.ts b/slack-broker/src/routing/forward.ts index 7eb83db..c1dcbeb 100644 --- a/slack-broker/src/routing/forward.ts +++ b/slack-broker/src/routing/forward.ts @@ -50,6 +50,16 @@ export async function forwardEvent( return { ok: false, error: "workspace missing server configuration" }; } + // Enforce HTTPS for all server callback URLs + try { + const url = new URL(workspace.server_url); + if (url.protocol !== "https:") { + return { ok: false, error: "server URL must use HTTPS" }; + } + } catch { + return { ok: false, error: "invalid server URL" }; + } + // Serialize and encrypt const plaintext = encodeUTF8(JSON.stringify(event)); const serverPubkey = decodeBase64(workspace.server_pubkey); diff --git a/slack-broker/src/routing/registry.ts b/slack-broker/src/routing/registry.ts index 869fe82..b32439c 100644 --- a/slack-broker/src/routing/registry.ts +++ b/slack-broker/src/routing/registry.ts @@ -10,7 +10,8 @@ * - inactive: server unlinked or heartbeat timed out */ -import { encodeBase64 } from "../util/encoding.js"; +import nacl from "tweetnacl"; +import { encodeBase64, decodeBase64 } from "../util/encoding.js"; export type WorkspaceStatus = "pending" | "active" | "inactive"; @@ -60,9 +61,48 @@ export async function putWorkspace( await kv.put(`workspace:${record.workspace_id}`, JSON.stringify(record)); } +/** + * Encrypt a bot token with nacl.secretbox for storage in KV. + * Uses the first 32 bytes of the broker's private key as the secretbox key. + * + * @returns base64-encoded nonce + ciphertext + */ +export function encryptBotToken(botToken: string, brokerKeyBase64: string): string { + const key = decodeBase64(brokerKeyBase64).slice(0, nacl.secretbox.keyLength); + const nonce = nacl.randomBytes(nacl.secretbox.nonceLength); + const plaintext = new TextEncoder().encode(botToken); + const ciphertext = nacl.secretbox(plaintext, nonce, key); + // Output: nonce || ciphertext + const combined = new Uint8Array(nonce.length + ciphertext.length); + combined.set(nonce, 0); + combined.set(ciphertext, nonce.length); + return encodeBase64(combined); +} + +/** + * Decrypt a bot token from KV storage. + * + * @param encrypted - base64-encoded nonce + ciphertext (from encryptBotToken) + * @param brokerKeyBase64 - the broker's private key (base64) + * @returns the plaintext bot token + */ +export function decryptBotToken(encrypted: string, brokerKeyBase64: string): string { + const key = decodeBase64(brokerKeyBase64).slice(0, nacl.secretbox.keyLength); + const combined = decodeBase64(encrypted); + const nonce = combined.slice(0, nacl.secretbox.nonceLength); + const ciphertext = combined.slice(nacl.secretbox.nonceLength); + const plaintext = nacl.secretbox.open(ciphertext, nonce, key); + if (!plaintext) { + throw new Error("decryptBotToken: decryption failed — invalid key or corrupted data"); + } + return new TextDecoder().decode(plaintext); +} + /** * Create a pending workspace record after OAuth. - * The bot_token is stored as-is (Cloudflare KV is encrypted at rest). + * The bot_token is encrypted with nacl.secretbox before storage. + * + * @param brokerKeyBase64 - the broker's private key for encrypting the bot token */ export async function createPendingWorkspace( kv: KVNamespace, @@ -70,6 +110,7 @@ export async function createPendingWorkspace( teamName: string, botToken: string, authCodeHash: string, + brokerKeyBase64: string, ): Promise { const record: WorkspaceRecord = { workspace_id: workspaceId, @@ -77,7 +118,7 @@ export async function createPendingWorkspace( server_url: "", server_pubkey: "", server_signing_pubkey: "", - bot_token: botToken, + bot_token: encryptBotToken(botToken, brokerKeyBase64), status: "pending", updated_at: new Date().toISOString(), auth_code_hash: authCodeHash, @@ -88,6 +129,10 @@ export async function createPendingWorkspace( /** * Activate a workspace by registering a server. + * + * Clears auth_code_hash after activation to prevent auth code reuse. + * Returns null if the workspace doesn't exist or is already active + * (active workspaces must be deactivated before re-registering). */ export async function activateWorkspace( kv: KVNamespace, @@ -99,11 +144,17 @@ export async function activateWorkspace( const record = await getWorkspace(kv, workspaceId); if (!record) return null; + // Reject re-registration of already-active workspaces. + // The current server must unregister first (DELETE /api/register). + if (record.status === "active") return null; + record.server_url = serverUrl; record.server_pubkey = serverPubkey; record.server_signing_pubkey = serverSigningPubkey; record.status = "active"; record.updated_at = new Date().toISOString(); + // Clear auth_code_hash — one-time use only. Prevents hijacking via code reuse. + record.auth_code_hash = ""; await putWorkspace(kv, record); return record; } @@ -128,13 +179,24 @@ export async function deactivateWorkspace( } /** - * Hash an auth code for storage (SHA-256, hex-encoded). - * We never store raw auth codes. + * Hash an auth code for storage using HMAC-SHA256, keyed with a broker secret. + * We never store raw auth codes. HMAC prevents offline brute-force without + * knowledge of the broker's secret key. + * + * @param authCode - the raw auth code to hash + * @param brokerSecret - the broker's private key (used as HMAC key) */ -export async function hashAuthCode(authCode: string): Promise { - const encoded = new TextEncoder().encode(authCode); - const hash = await crypto.subtle.digest("SHA-256", encoded); - const bytes = new Uint8Array(hash); +export async function hashAuthCode(authCode: string, brokerSecret: string): Promise { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(brokerSecret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const mac = await crypto.subtle.sign("HMAC", key, encoder.encode(authCode)); + const bytes = new Uint8Array(mac); return Array.from(bytes) .map((b) => b.toString(16).padStart(2, "0")) .join(""); diff --git a/slack-broker/src/slack/oauth.ts b/slack-broker/src/slack/oauth.ts index 09b66b2..3a45489 100644 --- a/slack-broker/src/slack/oauth.ts +++ b/slack-broker/src/slack/oauth.ts @@ -12,7 +12,7 @@ * server registration to prove workspace ownership. */ -import { createPendingWorkspace, hashAuthCode } from "../routing/registry.js"; +import { createPendingWorkspace, getWorkspace, hashAuthCode } from "../routing/registry.js"; import type { Env } from "../index.js"; /** Required OAuth scopes for the Prime app. */ @@ -129,17 +129,34 @@ export async function handleOAuthCallback( ); } + // Reject re-install if workspace is already active. + // The current server must unregister first to prevent silent disconnection. + const existing = await getWorkspace(env.WORKSPACE_ROUTING, tokenData.team.id); + if (existing && existing.status === "active") { + return new Response( + ` + +

⚠️ Workspace already active

+

Baudbot is already connected to ${escapeHtml(tokenData.team.name)} with an active server.

+

To re-install, first unregister the current server (run baudbot unregister), then try again.

+ +`, + { status: 200, headers: { "Content-Type": "text/html" } }, + ); + } + // Generate auth code for server registration const authCode = generateRandomString(32); - const authCodeHashed = await hashAuthCode(authCode); + const authCodeHashed = await hashAuthCode(authCode, env.BROKER_PRIVATE_KEY); - // Store workspace with pending status + // Store workspace with pending status (bot token encrypted at rest) await createPendingWorkspace( env.WORKSPACE_ROUTING, tokenData.team.id, tokenData.team.name, tokenData.access_token, authCodeHashed, + env.BROKER_PRIVATE_KEY, ); // Return success page with auth code diff --git a/slack-broker/test/integration.test.ts b/slack-broker/test/integration.test.ts index 38d077c..2f07c8b 100644 --- a/slack-broker/test/integration.test.ts +++ b/slack-broker/test/integration.test.ts @@ -20,9 +20,13 @@ import { activateWorkspace, getWorkspace, hashAuthCode, + decryptBotToken, type KVNamespace, } from "../src/routing/registry.js"; +/** Test broker key (base64-encoded 32-byte key). */ +const TEST_BROKER_KEY = encodeBase64(nacl.randomBytes(32)); + /** In-memory KV mock. */ function createMockKV(): KVNamespace { const store = new Map(); @@ -246,21 +250,25 @@ describe("end-to-end registration flow", () => { it("full OAuth → register → activate cycle", async () => { // 1. OAuth completes — create pending workspace const authCode = "secret-auth-code-12345"; - const authCodeHashed = await hashAuthCode(authCode); + const authCodeHashed = await hashAuthCode(authCode, TEST_BROKER_KEY); - await createPendingWorkspace(kv, "T123", "Test Team", "xoxb-fake-token", authCodeHashed); + await createPendingWorkspace(kv, "T123", "Test Team", "xoxb-fake-token", authCodeHashed, TEST_BROKER_KEY); // Verify pending state let ws = await getWorkspace(kv, "T123"); expect(ws!.status).toBe("pending"); expect(ws!.server_url).toBe(""); + // Verify bot token is encrypted (not plaintext) + expect(ws!.bot_token).not.toBe("xoxb-fake-token"); + expect(decryptBotToken(ws!.bot_token, TEST_BROKER_KEY)).toBe("xoxb-fake-token"); + // 2. Server generates keys and registers const serverBoxKeypair = nacl.box.keyPair(); const serverSignKeypair = nacl.sign.keyPair(); // Verify auth code matches - const providedHash = await hashAuthCode(authCode); + const providedHash = await hashAuthCode(authCode, TEST_BROKER_KEY); expect(providedHash).toBe(ws!.auth_code_hash); // 3. Activate @@ -274,20 +282,28 @@ describe("end-to-end registration flow", () => { expect(activated!.status).toBe("active"); expect(activated!.server_url).toBe("https://my-server.example.com/broker/inbound"); + // Auth code hash should be cleared after activation + expect(activated!.auth_code_hash).toBe(""); // 4. Verify the stored keys can be used for crypto ws = await getWorkspace(kv, "T123"); const storedPubkey = decodeBase64(ws!.server_pubkey); expect(storedPubkey).toEqual(serverBoxKeypair.publicKey); + + // 5. Re-registration should be rejected (workspace is active) + const reactivated = await activateWorkspace( + kv, "T123", "https://evil.example.com", "pk", "spk", + ); + expect(reactivated).toBeNull(); }); it("rejects registration with wrong auth code", async () => { const authCode = "correct-code"; - const authCodeHashed = await hashAuthCode(authCode); + const authCodeHashed = await hashAuthCode(authCode, TEST_BROKER_KEY); - await createPendingWorkspace(kv, "T123", "Test Team", "xoxb-fake", authCodeHashed); + await createPendingWorkspace(kv, "T123", "Test Team", "xoxb-fake", authCodeHashed, TEST_BROKER_KEY); - const wrongHash = await hashAuthCode("wrong-code"); + const wrongHash = await hashAuthCode("wrong-code", TEST_BROKER_KEY); const ws = await getWorkspace(kv, "T123"); expect(wrongHash).not.toBe(ws!.auth_code_hash); }); diff --git a/slack-broker/test/routing.test.ts b/slack-broker/test/routing.test.ts index 67b9399..4a403ce 100644 --- a/slack-broker/test/routing.test.ts +++ b/slack-broker/test/routing.test.ts @@ -10,6 +10,8 @@ import { activateWorkspace, deactivateWorkspace, hashAuthCode, + encryptBotToken, + decryptBotToken, type WorkspaceRecord, type KVNamespace, } from "../src/routing/registry.js"; @@ -17,6 +19,9 @@ import { forwardEvent, type ForwardResult } from "../src/routing/forward.js"; import nacl from "tweetnacl"; import { encodeBase64 } from "../src/util/encoding.js"; +/** Test broker key (base64-encoded 32-byte key). */ +const TEST_BROKER_KEY = encodeBase64(nacl.randomBytes(32)); + /** In-memory KV mock. */ function createMockKV(): KVNamespace { const store = new Map(); @@ -45,12 +50,15 @@ describe("registry", () => { expect(result).toBeNull(); }); - it("creates a pending workspace", async () => { - const record = await createPendingWorkspace(kv, "T123", "Test Team", "xoxb-token", "hash123"); + it("creates a pending workspace with encrypted bot token", async () => { + const record = await createPendingWorkspace(kv, "T123", "Test Team", "xoxb-token", "hash123", TEST_BROKER_KEY); expect(record.workspace_id).toBe("T123"); expect(record.team_name).toBe("Test Team"); - expect(record.bot_token).toBe("xoxb-token"); + // bot_token is now encrypted — should NOT be plaintext + expect(record.bot_token).not.toBe("xoxb-token"); + // Decrypting should yield the original + expect(decryptBotToken(record.bot_token, TEST_BROKER_KEY)).toBe("xoxb-token"); expect(record.status).toBe("pending"); expect(record.auth_code_hash).toBe("hash123"); expect(record.server_url).toBe(""); @@ -61,8 +69,8 @@ describe("registry", () => { expect(fetched).toEqual(record); }); - it("activates a workspace with server details", async () => { - await createPendingWorkspace(kv, "T123", "Test Team", "xoxb-token", "hash123"); + it("activates a workspace with server details and clears auth_code_hash", async () => { + await createPendingWorkspace(kv, "T123", "Test Team", "xoxb-token", "hash123", TEST_BROKER_KEY); const activated = await activateWorkspace( kv, @@ -77,6 +85,21 @@ describe("registry", () => { expect(activated!.server_url).toBe("https://server.example.com/broker/inbound"); expect(activated!.server_pubkey).toBe("server_pubkey_base64"); expect(activated!.server_signing_pubkey).toBe("server_signing_pubkey_base64"); + // Auth code hash should be cleared after activation + expect(activated!.auth_code_hash).toBe(""); + }); + + it("rejects activation of already-active workspace", async () => { + await createPendingWorkspace(kv, "T123", "Test Team", "xoxb-token", "hash123", TEST_BROKER_KEY); + await activateWorkspace(kv, "T123", "https://server1.example.com", "pk1", "spk1"); + + // Second activation should fail + const result = await activateWorkspace(kv, "T123", "https://server2.example.com", "pk2", "spk2"); + expect(result).toBeNull(); + + // Original server should still be registered + const ws = await getWorkspace(kv, "T123"); + expect(ws!.server_url).toBe("https://server1.example.com"); }); it("returns null when activating non-existent workspace", async () => { @@ -85,7 +108,7 @@ describe("registry", () => { }); it("deactivates a workspace", async () => { - await createPendingWorkspace(kv, "T123", "Test Team", "xoxb-token", "hash123"); + await createPendingWorkspace(kv, "T123", "Test Team", "xoxb-token", "hash123", TEST_BROKER_KEY); await activateWorkspace(kv, "T123", "https://server.example.com", "pk", "spk"); const result = await deactivateWorkspace(kv, "T123"); @@ -121,25 +144,79 @@ describe("registry", () => { }); }); -describe("hashAuthCode", () => { +describe("hashAuthCode (HMAC-SHA256)", () => { it("produces a hex string", async () => { - const hash = await hashAuthCode("test-code"); + const hash = await hashAuthCode("test-code", "broker-secret"); expect(hash).toMatch(/^[0-9a-f]{64}$/); }); it("is deterministic", async () => { - const h1 = await hashAuthCode("same-code"); - const h2 = await hashAuthCode("same-code"); + const h1 = await hashAuthCode("same-code", "broker-secret"); + const h2 = await hashAuthCode("same-code", "broker-secret"); expect(h1).toBe(h2); }); it("differs for different inputs", async () => { - const h1 = await hashAuthCode("code-a"); - const h2 = await hashAuthCode("code-b"); + const h1 = await hashAuthCode("code-a", "broker-secret"); + const h2 = await hashAuthCode("code-b", "broker-secret"); + expect(h1).not.toBe(h2); + }); + + it("differs for different secrets (keyed)", async () => { + const h1 = await hashAuthCode("same-code", "secret-1"); + const h2 = await hashAuthCode("same-code", "secret-2"); expect(h1).not.toBe(h2); }); }); +describe("workspace_id validation", () => { + it("accepts valid Slack team IDs", () => { + expect(/^T[A-Z0-9]+$/.test("T09192W1Z34")).toBe(true); + expect(/^T[A-Z0-9]+$/.test("T123")).toBe(true); + expect(/^T[A-Z0-9]+$/.test("TABCDEF012")).toBe(true); + }); + + it("rejects IDs with pipe delimiter (injection)", () => { + expect(/^T[A-Z0-9]+$/.test("T123|evil")).toBe(false); + }); + + it("rejects IDs that don't start with T", () => { + expect(/^T[A-Z0-9]+$/.test("U123")).toBe(false); + expect(/^T[A-Z0-9]+$/.test("123")).toBe(false); + }); + + it("rejects empty or T-only IDs", () => { + expect(/^T[A-Z0-9]+$/.test("")).toBe(false); + expect(/^T[A-Z0-9]+$/.test("T")).toBe(false); + }); +}); + +describe("bot token encryption", () => { + it("encrypts and decrypts a bot token", () => { + const token = "xoxb-1234567890-abcdefghij"; + const encrypted = encryptBotToken(token, TEST_BROKER_KEY); + expect(encrypted).not.toBe(token); + expect(decryptBotToken(encrypted, TEST_BROKER_KEY)).toBe(token); + }); + + it("different encryptions produce different ciphertexts (random nonce)", () => { + const token = "xoxb-same-token"; + const e1 = encryptBotToken(token, TEST_BROKER_KEY); + const e2 = encryptBotToken(token, TEST_BROKER_KEY); + expect(e1).not.toBe(e2); + // Both decrypt to the same value + expect(decryptBotToken(e1, TEST_BROKER_KEY)).toBe(token); + expect(decryptBotToken(e2, TEST_BROKER_KEY)).toBe(token); + }); + + it("fails to decrypt with wrong key", () => { + const token = "xoxb-secret"; + const wrongKey = encodeBase64(nacl.randomBytes(32)); + const encrypted = encryptBotToken(token, TEST_BROKER_KEY); + expect(() => decryptBotToken(encrypted, wrongKey)).toThrow("decryption failed"); + }); +}); + describe("forwardEvent", () => { const brokerSignKeypair = nacl.sign.keyPair(); @@ -180,6 +257,13 @@ describe("forwardEvent", () => { expect(result.error).toContain("missing server configuration"); }); + it("rejects non-HTTPS server URL", async () => { + const workspace = makeActiveWorkspace({ server_url: "http://server.example.com/inbound" }); + const result = await forwardEvent({}, workspace, brokerSignKeypair.secretKey); + expect(result.ok).toBe(false); + expect(result.error).toContain("HTTPS"); + }); + it("forwards an event to a server (mocked fetch)", async () => { const workspace = makeActiveWorkspace(); const event = { type: "event_callback", event: { type: "message", text: "hi" } };