diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3ce41af..6698cfe 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,11 +1,11 @@ { "name": "slack", - "description": "Slack integration for searching messages, sending communications, managing canvases, and more", + "description": "Slack integration for searching messages, sending communications, managing canvases, and real-time bidirectional messaging via Channels", "version": "1.0.0", "author": { "name": "Slack", "url": "https://slack.com" }, - "homepage": "https://github.com/slackapi/slack-mcp-cursor-plugin", + "homepage": "https://github.com/slackapi/slack-mcp-plugin", "license": "MIT" } diff --git a/.gitignore b/.gitignore index 4c5f206..a738e42 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ .claude/ +.env +node_modules/ +bun.lock diff --git a/.mcp.json b/.mcp.json index cc99b48..ac88620 100644 --- a/.mcp.json +++ b/.mcp.json @@ -7,6 +7,14 @@ "clientId": "1601185624273.8899143856786", "callbackPort": 3118 } + }, + "slack-channel": { + "command": "npx", + "args": ["tsx", "./src/index.ts"], + "env": { + "SLACK_BOT_TOKEN": "", + "SLACK_APP_TOKEN": "" + } } } } diff --git a/CLAUDE.md b/CLAUDE.md index 7621b89..d49d6a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Slack Plugin -This plugin integrates Slack with Claude Code, providing tools to search, read, and send messages in Slack. +This plugin integrates Slack with Claude Code, providing tools to search, read, and send messages in Slack. It also includes a **channel server** for real-time bidirectional Slack messaging via Socket Mode. ## Commands @@ -14,3 +14,28 @@ This plugin integrates Slack with Claude Code, providing tools to search, read, - **slack-messaging** — Guidance for composing well-formatted Slack messages using standard markdown - **slack-search** — Guidance for effectively searching Slack to find messages, files, channels, and people + +## Channel Server (Research Preview) + +The `slack-channel` MCP server enables real-time Slack messaging as a Claude Code Channel. It runs as a local subprocess using Socket Mode — no public URL needed. + +### Tools + +- **reply** — Send a message to a Slack channel or thread (`channel_id`, `text`, optional `thread_ts`) +- **react** — Add an emoji reaction to a message (`channel_id`, `timestamp`, `emoji`) +- **manage_access** — Add, remove, or pair users in the access allowlist (`action`: `add_user` / `remove_user` / `pair_user`, `value`: Slack user ID) +- **manage_channels** — Watch or unwatch channels (`action`: `watch` / `unwatch`, `channel_id`) + +### Setup + +Requires a Slack app with Socket Mode and two tokens: +- `SLACK_BOT_TOKEN` (`xoxb-...`) — Bot User OAuth Token +- `SLACK_APP_TOKEN` (`xapp-...`) — App-Level Token for Socket Mode + +See `docs/slack-app-setup.md` for detailed Slack app creation instructions. + +### Running + +``` +claude --dangerously-load-development-channels server:slack-channel +``` diff --git a/README.md b/README.md index 7c4992a..dda6790 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,82 @@ Add the following configuration to connect to the remote Slack MCP server: Save the configuration. You will also see a connect button once added. Click that to authenticate into your Slack Workspace. +## Channels for Claude Code (Research Preview) + +The Channels feature lets Claude Code receive and respond to messages directly in Slack—via DMs or channel mentions—using a locally-run bot server. + +### Slack App Setup + +1. Create a new Slack app at [api.slack.com/apps](https://api.slack.com/apps) and select **Socket Mode**. +2. Under **OAuth & Permissions**, add the following bot token scopes: + - `chat:write`, `reactions:write` + - `channels:join`, `channels:read`, `channels:history` + - `groups:read`, `im:read`, `im:history` + - `users:read`, `app_mentions:read` +3. Under **Socket Mode**, enable it and generate an **App-Level Token** with the `connections:write` scope. This token begins with `xapp-`. +4. Under **Event Subscriptions → Subscribe to bot events**, add: + - `message.im`, `message.channels`, `app_mention`, `reaction_added` +5. Install the app to your workspace and copy the **Bot User OAuth Token** (`xoxb-...`). + +### Configuration + +Add the `slack-channel` server entry to your `.mcp.json` alongside the existing `slack` remote server: + +```json +{ + "mcpServers": { + "slack": { + "type": "http", + "url": "https://mcp.slack.com/mcp", + "oauth": { + "clientId": "1601185624273.8899143856786", + "callbackPort": 3118 + } + }, + "slack-channel": { + "command": "npx", + "args": ["tsx", "./src/index.ts"], + "env": { + "SLACK_BOT_TOKEN": "xoxb-your-bot-token", + "SLACK_APP_TOKEN": "xapp-your-app-token" + } + } + } +} +``` + +Alternatively, set `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` as environment variables. + +To pre-configure which Slack users are allowed to interact with the bot, create `~/.slack-channel/settings.json`: + +```json +{ + "gating": { + "mode": "per-user", + "allowedUsers": ["U012AB3CD", "U098ZY7WX"] + }, + "watchedChannels": [] +} +``` + +### Running + +Start Claude Code with the channel server enabled: + +``` +claude --dangerously-load-development-channels server:slack-channel +``` + +### Pairing + +On the first run with an empty allowlist, DM the bot in Slack. It will reply with a pairing code. Send: + +``` +pair +``` + +This completes pairing and adds you to the allowlist. Once paired, you can ask Claude to pair additional users on your behalf. + ## Usage Examples Once configured, you can interact with Slack through your AI assistant using natural language: diff --git a/docs/slack-app-setup.md b/docs/slack-app-setup.md new file mode 100644 index 0000000..8b8cea0 --- /dev/null +++ b/docs/slack-app-setup.md @@ -0,0 +1,151 @@ +# Slack App Setup for Channels + +Step-by-step guide to create and configure the Slack app needed for the channel server. + +## 1. Create the App + +1. Go to [api.slack.com/apps](https://api.slack.com/apps) +2. Click **Create New App** +3. Choose **From scratch** +4. Name it something like `Claude Code Channel` (or whatever you prefer) +5. Select your workspace +6. Click **Create App** + +## 2. Enable Socket Mode + +1. In the left sidebar, click **Socket Mode** +2. Toggle **Enable Socket Mode** to ON +3. You'll be prompted to create an App-Level Token: + - Name it `socket-mode` (or anything descriptive) + - Add the scope `connections:write` + - Click **Generate** +4. **Copy the token** — it starts with `xapp-`. This is your `SLACK_APP_TOKEN`. Save it somewhere safe; you won't see it again. + +## 3. Add Bot Token Scopes + +1. In the left sidebar, click **OAuth & Permissions** +2. Scroll down to **Scopes** > **Bot Token Scopes** +3. Click **Add an OAuth Scope** and add each of these: + +| Scope | Purpose | +|---|---| +| `chat:write` | Send messages and replies | +| `reactions:write` | Add emoji reactions | +| `channels:join` | Join public channels when asked to watch them | +| `channels:read` | Read channel info (names, members) | +| `channels:history` | Read messages in public channels the bot is in | +| `groups:read` | Read private channel info | +| `im:read` | Read DM channel info | +| `im:history` | Read DM messages sent to the bot | +| `users:read` | Look up user names and profiles | +| `app_mentions:read` | Receive @mention events | + +## 4. Subscribe to Events + +1. In the left sidebar, click **Event Subscriptions** +2. Toggle **Enable Events** to ON +3. Expand **Subscribe to bot events** +4. Click **Add Bot User Event** and add each of these: + +| Event | Purpose | +|---|---| +| `message.im` | DMs to the bot | +| `message.channels` | Messages in public channels the bot is in | +| `app_mention` | @mentions of the bot in any channel | +| `reaction_added` | Emoji reactions on messages | + +5. Click **Save Changes** at the bottom + +## 5. Enable the Messages Tab + +This allows users to DM the bot — required for pairing and direct interaction. + +1. In the left sidebar, click **App Home** +2. Scroll down to **Show Tabs** +3. Check **Messages Tab** +4. Make sure **"Allow users to send Slash commands and messages from the messages tab"** is checked + +## 6. Enable Interactivity + +Required for the permission relay buttons (Approve/Deny). + +1. In the left sidebar, click **Interactivity & Shortcuts** +2. Toggle **Interactivity** to ON +3. With Socket Mode enabled, no Request URL is needed — Bolt handles it automatically +4. Click **Save Changes** + +## 7. Install the App to Your Workspace + +1. In the left sidebar, click **Install App** +2. Click **Install to Workspace** +3. Review the permissions and click **Allow** +4. **Copy the Bot User OAuth Token** — it starts with `xoxb-`. This is your `SLACK_BOT_TOKEN`. + +## 8. Configure the Channel Server + +Add your tokens to `.mcp.json`: + +```json +{ + "mcpServers": { + "slack": { + "type": "http", + "url": "https://mcp.slack.com/mcp", + "oauth": { + "clientId": "1601185624273.8899143856786", + "callbackPort": 3118 + } + }, + "slack-channel": { + "command": "npx", + "args": ["tsx", "./src/index.ts"], + "env": { + "SLACK_BOT_TOKEN": "xoxb-your-token-here", + "SLACK_APP_TOKEN": "xapp-your-token-here" + } + } + } +} +``` + +## 9. Test Standalone (Without Claude Code) + +Run the server directly to verify the Slack connection works: + +```bash +SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... npx tsx src/index.ts +``` + +Expected output on stderr: +``` +[slack-channel] connected to Slack as your-bot-name (U...) +[slack-channel] bootstrap mode: DM the bot to start pairing +[slack-channel] ready +``` + +If you see `ready`, the Slack connection is working. Press Ctrl+C to stop. + +**Troubleshooting:** +- `SLACK_BOT_TOKEN is missing or invalid` — Check the token starts with `xoxb-` +- `SLACK_APP_TOKEN is missing or invalid` — Check the token starts with `xapp-` +- Connection hangs — Verify Socket Mode is enabled in the Slack app settings +- `not_authed` error — Reinstall the app to your workspace (step 5) + +## 10. Test with Claude Code + +```bash +claude --dangerously-load-development-channels server:slack-channel +``` + +Then in Slack: +1. DM the bot — you should see a pairing code as an ephemeral message +2. Reply `pair ` — you should see "Paired successfully" +3. Send a message — Claude should receive it and can reply + +## 11. Invite the Bot to Channels (Optional) + +The bot only receives messages in channels it's been invited to. To monitor a channel: + +1. Go to the channel in Slack +2. Type `/invite @Claude Code Channel` (or whatever you named the bot) +3. Or ask Claude to watch it: "start watching #channel-name" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6fac001 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3254 @@ +{ + "name": "slack-channel-server", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "slack-channel-server", + "version": "0.0.1", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.28.0", + "@slack/bolt": "^4.6.0", + "tsx": "^4.19.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^6.0.2", + "vitest": "^4.1.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.5.tgz", + "integrity": "sha512-nGsF/4C7uzUj+Nj/4J+Zt0bYQ6bz33Phz8Lb2N80Mti1HjGclTJdXZ+9APC4kLvONbjxN1zfvYNd8FEcbBK/MQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.5.tgz", + "integrity": "sha512-Cv781jd0Rfj/paoNrul1/r4G0HLvuFKYh7C9uHZ2Pl8YXstzvCyyeWENTFR9qFnRzNMCjXmsulZuvosDg10Mog==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.5.tgz", + "integrity": "sha512-Oeghq+XFgh1pUGd1YKs4DDoxzxkoUkvko+T/IVKwlghKLvvjbGFB3ek8VEDBmNvqhwuL0CQS3cExdzpmUyIrgA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.5.tgz", + "integrity": "sha512-nQD7lspbzerlmtNOxYMFAGmhxgzn8Z7m9jgFkh6kpkjsAhZee1w8tJW3ZlW+N9iRePz0oPUDrYrXidCPSImD0Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.5.tgz", + "integrity": "sha512-I+Ya/MgC6rr8oRWGRDF3BXDfP8K1BVUggHqN6VI2lUZLdDi1IM1v2cy0e3lCPbP+pVcK3Tv8cgUhHse1kaNZZw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.5.tgz", + "integrity": "sha512-MCjQUtC8wWJn/pIPM7vQaO69BFgwPD1jriEdqwTCKzWjGgkMbcg+M5HzrOhPhuYe1AJjXlHmD142KQf+jnYj8A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.5.tgz", + "integrity": "sha512-X6xVS+goSH0UelYXnuf4GHLwpOdc8rgK/zai+dKzBMnncw7BTQIwquOodE7EKvY2UVUetSqyAfyZC1D+oqLQtg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.5.tgz", + "integrity": "sha512-233X1FGo3a8x1ekLB6XT69LfZ83vqz+9z3TSEQCTYfMNY880A97nr81KbPcAMl9rmOFp11wO0dP+eB18KU/Ucg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.5.tgz", + "integrity": "sha512-0wkVrYHG4sdCCN/bcwQ7yYMXACkaHc3UFeaEOwSVW6e5RycMageYAFv+JS2bKLwHyeKVUvtoVH+5/RHq0fgeFw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.5.tgz", + "integrity": "sha512-euKkilsNOv7x/M1NKsx5znyprbpsRFIzTV6lWziqJch7yWYayfLtZzDxDTl+LSQDJYAjd9TVb/Kt5UKIrj2e4A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.5.tgz", + "integrity": "sha512-hVRQX4+P3MS36NxOy24v/Cdsimy/5HYePw+tmPqnNN1fxV0bPrFWR6TMqwXPwoTM2VzbkA+4lbHWUKDd5ZDA/w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.5.tgz", + "integrity": "sha512-mKqqRuOPALI8nDzhOBmIS0INvZOOFGGg5n1osGIXAx8oersceEbKd4t1ACNTHM3sJBXGFAlEgqM+svzjPot+ZQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.5.tgz", + "integrity": "sha512-EE/QXH9IyaAj1qeuIV5+/GZkBTipgGO782Ff7Um3vPS9cvLhJJeATy4Ggxikz2inZ46KByamMn6GqtqyVjhenA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.5.tgz", + "integrity": "sha512-0V2iF1RGxBf1b7/BjurA5jfkl7PtySjom1r6xOK2q9KWw/XCpAdtB6KNMO+9xx69yYfSCRR9FE0TyKfHA2eQMw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.5.tgz", + "integrity": "sha512-rYxThBx6G9HN6tFNuvB/vykeLi4VDsm5hE5pVwzqbAjZEARQrWu3noZSfbEnPZ/CRXP3271GyFk/49up2W190g==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.5.tgz", + "integrity": "sha512-uEP2q/4qgd8goEUc4QIdU/1P2NmEtZ/zX5u3OpLlCGhJIuBIv0s0wr7TB2nBrd3/A5XIdEkkS5ZLF0ULuvaaYQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.5.tgz", + "integrity": "sha512-+Gq47Wqq6PLOOZuBzVSII2//9yyHNKZLuwfzCemqexqOQCSz0zy0O26kIzyp9EMNMK+nZ0tFHBZrCeVUuMs/ew==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.5.tgz", + "integrity": "sha512-3F/5EG8VHfN/I+W5cO1/SV2H9Q/5r7vcHabMnBqhHK2lTWOh3F8vixNzo8lqxrlmBtZVFpW8pmITHnq54+Tq4g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.5.tgz", + "integrity": "sha512-28t+Sj3CPN8vkMOlZotOmDgilQwVvxWZl7b8rxpn73Tt/gCnvrHxQUMng4uu3itdFvrtba/1nHejvxqz8xgEMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.5.tgz", + "integrity": "sha512-Doz/hKtiuVAi9hMsBMpwBANhIZc8l238U2Onko3t2xUp8xtM0ZKdDYHMnm/qPFVthY8KtxkXaocwmMh6VolzMA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.5.tgz", + "integrity": "sha512-WfGVaa1oz5A7+ZFPkERIbIhKT4olvGl1tyzTRaB5yoZRLqC0KwaO95FeZtOdQj/oKkjW57KcVF944m62/0GYtA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.5.tgz", + "integrity": "sha512-Xh+VRuh6OMh3uJ0JkCjI57l+DVe7VRGBYymen8rFPnTVgATBwA6nmToxM2OwTlSvrnWpPKkrQUj93+K9huYC6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.5.tgz", + "integrity": "sha512-aC1gpJkkaUADHuAdQfuVTnqVUTLqqUNhAvEwHwVWcnVVZvNlDPGA0UveZsfXJJ9T6k9Po4eHi3c02gbdwO3g6w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.5.tgz", + "integrity": "sha512-0UNx2aavV0fk6UpZcwXFLztA2r/k9jTUa7OW7SAea1VYUhkug99MW1uZeXEnPn5+cHOd0n8myQay6TlFnBR07w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.5.tgz", + "integrity": "sha512-5nlJ3AeJWCTSzR7AEqVjT/faWyqKU86kCi1lLmxVqmNR+j4HrYdns+eTGjS/vmrzCIe8inGQckUadvS0+JkKdQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.5.tgz", + "integrity": "sha512-PWypQR+d4FLfkhBIV+/kHsUELAnMpx1bRvvsn3p+/sAERbnCzFrtDRG2Xw5n+2zPxBK2+iaP+vetsRl4Ti7WgA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "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/@modelcontextprotocol/sdk": { + "version": "1.28.0", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@slack/bolt": { + "version": "4.6.0", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.0", + "@slack/oauth": "^3.0.4", + "@slack/socket-mode": "^2.0.5", + "@slack/types": "^2.18.0", + "@slack/web-api": "^7.12.0", + "axios": "^1.12.0", + "express": "^5.0.0", + "path-to-regexp": "^8.1.0", + "raw-body": "^3", + "tsscmp": "^1.0.6" + }, + "engines": { + "node": ">=18", + "npm": ">=8.6.0" + }, + "peerDependencies": { + "@types/express": "^5.0.0" + } + }, + "node_modules/@slack/logger": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "@types/node": ">=18" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/oauth": { + "version": "3.0.5", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/web-api": "^7.15.0", + "@types/jsonwebtoken": "^9", + "@types/node": ">=18", + "jsonwebtoken": "^9" + }, + "engines": { + "node": ">=18", + "npm": ">=8.6.0" + } + }, + "node_modules/@slack/socket-mode": { + "version": "2.0.6", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/web-api": "^7.15.0", + "@types/node": ">=18", + "@types/ws": "^8", + "eventemitter3": "^5", + "ws": "^8" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/types": { + "version": "2.20.1", + "license": "MIT", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api": { + "version": "7.15.0", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/types": "^2.20.1", + "@types/node": ">=18", + "@types/retry": "0.12.0", + "axios": "^1.13.5", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", + "is-electron": "2.2.2", + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "license": "MIT", + "peer": true + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "license": "MIT", + "peer": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "license": "MIT", + "peer": true + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "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/asynckit": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.5.tgz", + "integrity": "sha512-zdQoHBjuDqKsvV5OPaWansOwfSQ0Js+Uj9J85TBvj3bFW1JjWTSULMRwdQAc8qMeIScbClxeMK0jlrtB9linhA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.5", + "@esbuild/android-arm": "0.27.5", + "@esbuild/android-arm64": "0.27.5", + "@esbuild/android-x64": "0.27.5", + "@esbuild/darwin-arm64": "0.27.5", + "@esbuild/darwin-x64": "0.27.5", + "@esbuild/freebsd-arm64": "0.27.5", + "@esbuild/freebsd-x64": "0.27.5", + "@esbuild/linux-arm": "0.27.5", + "@esbuild/linux-arm64": "0.27.5", + "@esbuild/linux-ia32": "0.27.5", + "@esbuild/linux-loong64": "0.27.5", + "@esbuild/linux-mips64el": "0.27.5", + "@esbuild/linux-ppc64": "0.27.5", + "@esbuild/linux-riscv64": "0.27.5", + "@esbuild/linux-s390x": "0.27.5", + "@esbuild/linux-x64": "0.27.5", + "@esbuild/netbsd-arm64": "0.27.5", + "@esbuild/netbsd-x64": "0.27.5", + "@esbuild/openbsd-arm64": "0.27.5", + "@esbuild/openbsd-x64": "0.27.5", + "@esbuild/openharmony-arm64": "0.27.5", + "@esbuild/sunos-x64": "0.27.5", + "@esbuild/win32-arm64": "0.27.5", + "@esbuild/win32-ia32": "0.27.5", + "@esbuild/win32-x64": "0.27.5" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "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/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "3.0.7", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "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/express": { + "version": "5.2.1", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.9", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-electron": { + "version": "2.2.2", + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "license": "BSD-2-Clause" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "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/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-queue": { + "version": "6.6.2", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "4.0.7", + "license": "MIT" + }, + "node_modules/p-retry": { + "version": "4.6.2", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.0", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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/proxy-addr": { + "version": "2.0.7", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.0", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/router": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "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/tsscmp": { + "version": "1.0.6", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "6.0.2", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "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/wrappy": { + "version": "1.0.2", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "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/zod": { + "version": "4.3.6", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1627b3f --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "slack-channel-server", + "version": "0.0.1", + "type": "module", + "scripts": { + "start": "tsx src/index.ts", + "test": "vitest run" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.28.0", + "@slack/bolt": "^4.6.0", + "tsx": "^4.19.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^6.0.2", + "vitest": "^4.1.0" + } +} \ No newline at end of file diff --git a/skills/slack-messaging/SKILL.md b/skills/slack-messaging/SKILL.md index 103451f..07db38c 100644 --- a/skills/slack-messaging/SKILL.md +++ b/skills/slack-messaging/SKILL.md @@ -49,5 +49,5 @@ Not supported: ## Tone and Audience - Match the tone to the channel — `#general` is usually more formal than `#random`. -- Use emoji reactions instead of reply messages for simple acknowledgments (though note: the MCP tools can't add reactions, so suggest the user do this manually if appropriate). +- Use emoji reactions instead of reply messages for simple acknowledgments. The channel server's `react` tool can add reactions directly; if only the remote MCP server is available, suggest the user add reactions manually. - When writing announcements, use a clear structure: context, key info, call to action. diff --git a/src/bridge.ts b/src/bridge.ts new file mode 100644 index 0000000..7cb24d1 --- /dev/null +++ b/src/bridge.ts @@ -0,0 +1,561 @@ +import type { Server } from '@modelcontextprotocol/sdk/server/index.js' +import type { App } from '@slack/bolt' +import type { Gating } from './gating' +import type { Settings } from './settings' +import { writeSettings } from './settings' +import { writeFile, mkdir } from 'node:fs/promises' +import { dirname } from 'node:path' + +export interface ActiveContext { + userId: string + channelId: string + threadTs?: string +} + +interface SlackMessageEvent { + text: string + user: string + channel: string + channel_type: string + ts: string + thread_ts?: string +} + +interface SlackMentionEvent { + text: string + user: string + channel: string + ts: string + thread_ts?: string +} + +interface SlackReactionEvent { + user: string + reaction: string + item: { + type: string + channel: string + ts: string + } + item_user: string + event_ts: string +} + +interface PermissionRequest { + request_id: string + tool_name: string + description: string + input_preview: string +} + +const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z0-9]{5})\s*$/i + +export class Bridge { + private mcp: Server | null = null + private lastActiveContext: ActiveContext | null = null + private userNameCache = new Map() + private channelNameCache = new Map() + private settingsPath: string + + constructor( + private slackApp: App, + private gating: Gating, + private settings: Settings, + settingsPath: string = '', + ) { + this.settingsPath = settingsPath + } + + setMcpServer(mcp: Server): void { + this.mcp = mcp + } + + getLastActiveContext(): ActiveContext | null { + return this.lastActiveContext + } + + static parsePermissionVerdict(text: string): { requestId: string; behavior: 'allow' | 'deny' } | null { + const m = PERMISSION_REPLY_RE.exec(text) + if (!m) return null + return { + requestId: m[2].toLowerCase(), + behavior: m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny', + } + } + + async handleMessage(event: SlackMessageEvent): Promise { + // Check for pairing code response during bootstrap + if (this.gating.isBootstrapMode()) { + await this.handleBootstrapMessage(event) + return + } + + // Check for permission verdict before gating (must be from allowed user though) + if (this.gating.isAllowed(event.user)) { + const verdict = Bridge.parsePermissionVerdict(event.text) + if (verdict) { + await this.mcp!.notification({ + method: 'notifications/claude/channel/permission' as any, + params: { + request_id: verdict.requestId, + behavior: verdict.behavior, + }, + }) + return + } + } + + // Not allowed — check if they're responding to a pairing code, otherwise drop + if (!this.gating.isAllowed(event.user)) { + await this.handlePairingResponse(event) + return + } + + // Execution reaches here only for allowed users + + // Determine event type + const isDm = event.channel_type === 'im' + const isWatched = this.settings.watchedChannels.includes(event.channel) + + if (!isDm && !isWatched) return + + const eventType = isDm ? 'dm' : 'message' + await this.emitChannelNotification(eventType, event) + } + + async handleMention(event: SlackMentionEvent): Promise { + if (!this.gating.isAllowed(event.user)) return + + await this.emitChannelNotification('mention', { + text: event.text, + user: event.user, + channel: event.channel, + channel_type: 'channel', + ts: event.ts, + thread_ts: event.thread_ts, + }) + } + + async handleReaction(event: SlackReactionEvent, botUserId: string): Promise { + // Only emit for reactions on bot's own messages + if (event.item.type !== 'message' || event.item_user !== botUserId) return + if (!this.gating.isAllowed(event.user)) return + + const userName = await this.resolveUserName(event.user) + const channelName = await this.resolveChannelName(event.item.channel) + + const meta: Record = { + source: 'slack', + event: 'reaction', + user: event.user, + user_name: userName, + channel_id: event.item.channel, + emoji: event.reaction, + item_ts: event.item.ts, + ts: event.event_ts, + } + if (channelName) meta.channel_name = channelName + + this.lastActiveContext = { + userId: event.user, + channelId: event.item.channel, + } + + // Fetch the original message text for context + let messageText = '' + try { + const history = await this.slackApp.client.conversations.history({ + channel: event.item.channel, + latest: event.item.ts, + oldest: event.item.ts, + inclusive: true, + limit: 1, + }) + messageText = (history.messages as any)?.[0]?.text || '' + } catch { + // Non-fatal — proceed without message text + } + + const content = messageText + ? `Reaction :${event.reaction}: on message: "${messageText}"` + : `Reaction :${event.reaction}: on message` + + await this.mcp!.notification({ + method: 'notifications/claude/channel' as any, + params: { + content, + meta, + }, + }) + } + + async handleToolCall( + name: string, + args: Record, + ): Promise<{ content: Array<{ type: string; text: string }> }> { + try { + switch (name) { + case 'reply': + return await this.handleReply(args) + case 'react': + return await this.handleReact(args) + case 'manage_access': + return await this.handleManageAccess(args) + case 'manage_channels': + return await this.handleManageChannels(args) + default: + throw new Error(`unknown tool: ${name}`) + } + } catch (err) { + return { content: [{ type: 'text', text: `error: ${(err as Error).message}` }], isError: true } + } + } + + async handlePermissionRequest(params: PermissionRequest): Promise { + if (!this.lastActiveContext) { + console.error('[slack-channel] permission request dropped: no active context') + return + } + + const fallbackText = + `Claude wants to run \`${params.tool_name}\`: ${params.description}\n` + + `Reply \`yes ${params.request_id}\` or \`no ${params.request_id}\`` + + await this.slackApp.client.chat.postMessage({ + channel: this.lastActiveContext.channelId, + text: fallbackText, + thread_ts: this.lastActiveContext.threadTs, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Claude wants to run \`${params.tool_name}\`:*\n${params.description}`, + }, + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `\`${params.input_preview}\``, + }, + ], + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: 'Approve' }, + style: 'primary', + action_id: 'permission_approve', + value: params.request_id, + }, + { + type: 'button', + text: { type: 'plain_text', text: 'Deny' }, + style: 'danger', + action_id: 'permission_deny', + value: params.request_id, + }, + ], + }, + ], + }) + } + + async handlePermissionAction(requestId: string, approved: boolean, channelId: string, messageTs: string, userId?: string): Promise { + // Validate the acting user is in the allowlist + if (userId && !this.gating.isAllowed(userId)) { + console.error(`[slack-channel] permission action rejected: user ${userId} not in allowlist`) + return + } + + // Emit the verdict + await this.mcp!.notification({ + method: 'notifications/claude/channel/permission' as any, + params: { + request_id: requestId, + behavior: approved ? 'allow' : 'deny', + }, + }) + + // Update the message to remove buttons and show the result + const verdict = approved ? 'Approved' : 'Denied' + await this.slackApp.client.chat.update({ + channel: channelId, + ts: messageTs, + text: `Permission ${verdict.toLowerCase()}`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `~Permission request~ — *${verdict}*`, + }, + }, + ], + }) + } + + async resolveUserName(userId: string): Promise { + const cached = this.userNameCache.get(userId) + if (cached) return cached + + try { + const result = await this.slackApp.client.users.info({ user: userId }) + const name = (result.user as any)?.real_name || (result.user as any)?.name || userId + this.userNameCache.set(userId, name) + return name + } catch { + return userId + } + } + + async resolveChannelName(channelId: string): Promise { + const cached = this.channelNameCache.get(channelId) + if (cached) return cached + + try { + const result = await this.slackApp.client.conversations.info({ channel: channelId }) + const name = (result.channel as any)?.name || '' + if (name) this.channelNameCache.set(channelId, name) + return name + } catch { + return '' + } + } + + // --- Private helpers --- + + private async emitChannelNotification( + eventType: string, + event: SlackMessageEvent, + ): Promise { + const userName = await this.resolveUserName(event.user) + const channelName = event.channel_type !== 'im' + ? await this.resolveChannelName(event.channel) + : undefined + + const meta: Record = { + source: 'slack', + event: eventType, + user: event.user, + user_name: userName, + channel_id: event.channel, + ts: event.ts, + } + if (channelName) meta.channel_name = channelName + if (event.thread_ts) meta.thread_ts = event.thread_ts + + this.lastActiveContext = { + userId: event.user, + channelId: event.channel, + threadTs: event.thread_ts, + } + + await this.mcp!.notification({ + method: 'notifications/claude/channel' as any, + params: { + content: event.text, + meta, + }, + }) + } + + private async handleBootstrapMessage(event: SlackMessageEvent): Promise { + // Only accept pairing codes in DMs to keep them private + if (event.channel_type !== 'im') return + + const pairMatch = event.text.match(/^pair\s+([A-Z0-9]{6})\s*$/i) + if (pairMatch) { + const code = pairMatch[1].toUpperCase() + if (this.gating.verifyPairingCode(code, event.user)) { + this.gating.addUser(event.user) + await this.persistSettings() + await this.slackApp.client.chat.postEphemeral({ + channel: event.channel, + user: event.user, + text: 'Paired successfully. You now have access.', + }) + } else { + await this.slackApp.client.chat.postEphemeral({ + channel: event.channel, + user: event.user, + text: 'Invalid or expired pairing code.', + }) + } + return + } + + // First DM in bootstrap: send pairing code + if (event.channel_type === 'im') { + if (this.gating.hasPendingPairingCode()) { + await this.slackApp.client.chat.postEphemeral({ + channel: event.channel, + user: event.user, + text: 'Pairing already in progress, please try again shortly.', + }) + return + } + + const code = this.gating.createPairingCode(event.user) + if (code) { + console.log(`[slack-channel] pairing code: ${code}`) + await this.writePairingCodeFile(code) + await this.slackApp.client.chat.postEphemeral({ + channel: event.channel, + user: event.user, + text: `Your pairing code is: \`${code}\`\nReply with \`pair ${code}\` to complete pairing.`, + }) + } + } + } + + private async handlePairingResponse(event: SlackMessageEvent): Promise { + const pairMatch = event.text.match(/^pair\s+([A-Z0-9]{6})\s*$/i) + if (!pairMatch) return + + const code = pairMatch[1].toUpperCase() + if (this.gating.verifyPairingCode(code, event.user)) { + this.gating.addUser(event.user) + await this.persistSettings() + await this.slackApp.client.chat.postEphemeral({ + channel: event.channel, + user: event.user, + text: 'Paired successfully. You now have access.', + }) + } else { + await this.slackApp.client.chat.postEphemeral({ + channel: event.channel, + user: event.user, + text: 'Invalid or expired pairing code.', + }) + } + } + + private checkAdminAuth(): void { + if (!this.lastActiveContext) { + throw new Error('authorization error: no active context') + } + if (!this.gating.isAllowed(this.lastActiveContext.userId)) { + throw new Error('authorization error: caller not in allowlist') + } + } + + private async handleReply(args: Record) { + if (!this.lastActiveContext) { + throw new Error('authorization error: no active context') + } + await this.slackApp.client.chat.postMessage({ + channel: args.channel_id, + text: args.text, + thread_ts: args.thread_ts, + }) + return { content: [{ type: 'text', text: 'sent' }] } + } + + private async handleReact(args: Record) { + if (!this.lastActiveContext) { + throw new Error('authorization error: no active context') + } + await this.slackApp.client.reactions.add({ + channel: args.channel_id, + timestamp: args.timestamp, + name: args.emoji, + }) + return { content: [{ type: 'text', text: 'reacted' }] } + } + + private async handleManageAccess(args: Record) { + this.checkAdminAuth() + + switch (args.action) { + case 'add_user': + this.gating.addUser(args.value) + await this.persistSettings() + return { content: [{ type: 'text', text: `added ${args.value} to allowlist` }] } + + case 'remove_user': + this.gating.removeUser(args.value) + await this.persistSettings() + return { content: [{ type: 'text', text: `removed ${args.value} from allowlist` }] } + + case 'pair_user': { + const code = this.gating.createPairingCode(args.value) + if (!code) { + return { content: [{ type: 'text', text: 'pairing code already pending, try again shortly' }] } + } + // Open a DM with the target user and send the pairing code there + const dm = await this.slackApp.client.conversations.open({ users: args.value }) + const dmChannelId = (dm.channel as any)?.id + if (!dmChannelId) { + throw new Error(`failed to open DM with ${args.value}`) + } + await this.slackApp.client.chat.postEphemeral({ + channel: dmChannelId, + user: args.value, + text: `Your pairing code is: \`${code}\`\nReply with \`pair ${code}\` to complete pairing.`, + }) + return { content: [{ type: 'text', text: `pairing code sent to ${args.value}` }] } + } + + default: + throw new Error(`unknown action: ${args.action}`) + } + } + + private async handleManageChannels(args: Record) { + this.checkAdminAuth() + + switch (args.action) { + case 'watch': + if (!this.settings.watchedChannels.includes(args.channel_id)) { + // Try to join — ignore "already_in_channel" or missing scope errors + try { + await this.slackApp.client.conversations.join({ channel: args.channel_id }) + } catch (err: any) { + const slackError = err?.data?.error || err?.message || '' + if (slackError !== 'already_in_channel' && slackError !== 'missing_scope') { + throw err + } + } + this.settings.watchedChannels.push(args.channel_id) + await this.persistSettings() + } + return { content: [{ type: 'text', text: `now watching ${args.channel_id}` }] } + + case 'unwatch': { + const idx = this.settings.watchedChannels.indexOf(args.channel_id) + if (idx !== -1) { + this.settings.watchedChannels.splice(idx, 1) + await this.persistSettings() + } + return { content: [{ type: 'text', text: `stopped watching ${args.channel_id}` }] } + } + + default: + throw new Error(`unknown action: ${args.action}`) + } + } + + private async persistSettings(): Promise { + this.settings.gating.allowedUsers = this.gating.getAllowedUsers() + if (this.settingsPath) { + await writeSettings(this.settingsPath, this.settings) + } + } + + private async writePairingCodeFile(code: string): Promise { + try { + const path = this.settingsPath + ? `${dirname(this.settingsPath)}/pairing-code.txt` + : `${process.env.HOME}/.slack-channel/pairing-code.txt` + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, code) + } catch (err) { + console.error('[slack-channel] failed to write pairing code file:', err) + } + } +} diff --git a/src/gating.ts b/src/gating.ts new file mode 100644 index 0000000..85443c5 --- /dev/null +++ b/src/gating.ts @@ -0,0 +1,75 @@ +import { randomBytes } from 'node:crypto' +import type { Settings } from './settings' + +const CODE_TTL_MS = 5 * 60 * 1000 // 5 minutes +const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // no 0/O/1/I/L ambiguity + +interface PendingCode { + userId: string + timestamp: number +} + +export class Gating { + private allowedUsers: Set + private pendingCodes: Map = new Map() + + constructor(settings: Settings) { + this.allowedUsers = new Set(settings.gating.allowedUsers) + } + + isAllowed(userId: string): boolean { + return this.allowedUsers.has(userId) + } + + isBootstrapMode(): boolean { + return this.allowedUsers.size === 0 + } + + addUser(userId: string): void { + this.allowedUsers.add(userId) + } + + removeUser(userId: string): void { + this.allowedUsers.delete(userId) + } + + getAllowedUsers(): string[] { + return [...this.allowedUsers] + } + + createPairingCode(userId: string, now: number = Date.now()): string | null { + this.pruneExpired(now) + + // In bootstrap mode, only one code at a time + if (this.isBootstrapMode() && this.pendingCodes.size > 0) { + return null + } + + const bytes = randomBytes(6) + const code = Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join('') + + this.pendingCodes.set(code, { userId, timestamp: now }) + return code + } + + verifyPairingCode(code: string, userId: string): boolean { + this.pruneExpired() + const entry = this.pendingCodes.get(code.toUpperCase()) + if (!entry || entry.userId !== userId) return false + this.pendingCodes.delete(code.toUpperCase()) + return true + } + + hasPendingPairingCode(): boolean { + this.pruneExpired() + return this.pendingCodes.size > 0 + } + + private pruneExpired(now: number = Date.now()): void { + for (const [code, entry] of this.pendingCodes) { + if (now - entry.timestamp > CODE_TTL_MS) { + this.pendingCodes.delete(code) + } + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..b58a28f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import { readSettings } from './settings' +import { Gating } from './gating' +import { createMcpServer, connectMcp } from './mcp' +import { createSlackApp, registerEventHandlers, getBotUserId, startSlackApp } from './slack' +import { Bridge } from './bridge' + +// --- Validate environment --- +const botToken = process.env.SLACK_BOT_TOKEN +const appToken = process.env.SLACK_APP_TOKEN + +if (!botToken || !botToken.startsWith('xoxb-')) { + console.error('[slack-channel] SLACK_BOT_TOKEN is missing or invalid (must start with xoxb-)') + process.exit(1) +} + +if (!appToken || !appToken.startsWith('xapp-')) { + console.error('[slack-channel] SLACK_APP_TOKEN is missing or invalid (must start with xapp-)') + process.exit(1) +} + +// --- Load settings --- +const settingsPath = process.env.SLACK_CHANNEL_SETTINGS_PATH + || `${process.env.HOME}/.slack-channel/settings.json` + +const settings = await readSettings(settingsPath) + +// --- Wire up modules --- +const gating = new Gating(settings) +const slackApp = createSlackApp(botToken, appToken) + +// Bridge is created without MCP reference. setMcpServer() wires it up after MCP is created. +const bridge = new Bridge(slackApp, gating, settings, settingsPath) +const mcp = createMcpServer(bridge) +bridge.setMcpServer(mcp) + +// --- Get bot identity and register handlers before connecting --- +const botUserId = await getBotUserId(slackApp) +registerEventHandlers(slackApp, bridge, botUserId) + +// --- Start Slack (Socket Mode) — handlers already registered --- +await startSlackApp(slackApp) + +// --- Connect MCP (stdio) after Slack is confirmed connected --- +await connectMcp(mcp) + +if (gating.isBootstrapMode()) { + console.error('[slack-channel] bootstrap mode: DM the bot to start pairing') +} + +console.error('[slack-channel] ready') diff --git a/src/mcp.ts b/src/mcp.ts new file mode 100644 index 0000000..fe3fe56 --- /dev/null +++ b/src/mcp.ts @@ -0,0 +1,126 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { + ListToolsRequestSchema, + CallToolRequestSchema, +} from '@modelcontextprotocol/sdk/types.js' +import { z } from 'zod' +import type { Bridge } from './bridge' + +export const CHANNEL_INSTRUCTIONS = [ + 'Messages from Slack arrive as .', + 'Events: "dm" (direct message to bot), "mention" (@mention in a channel), "message" (watched channel), "reaction" (emoji on a bot message).', + 'Reply with the reply tool, passing channel_id and optionally thread_ts from the tag.', + 'Use the react tool to add emoji reactions.', + 'Use manage_access and manage_channels to administer the instance when asked.', +].join('\n') + +export const TOOL_DEFINITIONS = [ + { + name: 'reply', + description: 'Send a message back to a Slack channel or thread', + inputSchema: { + type: 'object' as const, + properties: { + channel_id: { type: 'string', description: 'Slack channel ID to send to' }, + text: { type: 'string', description: 'Message content' }, + thread_ts: { type: 'string', description: 'Thread timestamp to reply in-thread' }, + }, + required: ['channel_id', 'text'], + }, + }, + { + name: 'react', + description: 'Add an emoji reaction to a Slack message', + inputSchema: { + type: 'object' as const, + properties: { + channel_id: { type: 'string', description: 'Channel containing the message' }, + timestamp: { type: 'string', description: 'Message timestamp to react to' }, + emoji: { type: 'string', description: 'Emoji name without colons (e.g. thumbsup)' }, + }, + required: ['channel_id', 'timestamp', 'emoji'], + }, + }, + { + name: 'manage_access', + description: 'Add, remove, or pair users in the access allowlist', + inputSchema: { + type: 'object' as const, + properties: { + action: { + type: 'string', + enum: ['add_user', 'remove_user', 'pair_user'], + description: 'Action to perform', + }, + value: { type: 'string', description: 'Slack user ID (e.g. U12345ABC)' }, + }, + required: ['action', 'value'], + }, + }, + { + name: 'manage_channels', + description: 'Add or remove channels from the watch list', + inputSchema: { + type: 'object' as const, + properties: { + action: { + type: 'string', + enum: ['watch', 'unwatch'], + description: 'Action to perform', + }, + channel_id: { type: 'string', description: 'Slack channel ID' }, + }, + required: ['action', 'channel_id'], + }, + }, +] + +// Schema for permission_request notifications from Claude Code. +// Uses z.object with z.literal on the method field — this is how the MCP SDK's +// setNotificationHandler dispatches by method name (same pattern as the channels reference doc). +const PermissionRequestSchema = z.object({ + method: z.literal('notifications/claude/channel/permission_request'), + params: z.object({ + request_id: z.string(), + tool_name: z.string(), + description: z.string(), + input_preview: z.string(), + }), +}) + +export function createMcpServer(bridge: Bridge): Server { + const mcp = new Server( + { name: 'slack-channel', version: '0.0.1' }, + { + capabilities: { + experimental: { + 'claude/channel': {}, + 'claude/channel/permission': {}, + }, + tools: {}, + }, + instructions: CHANNEL_INSTRUCTIONS, + }, + ) + + mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: TOOL_DEFINITIONS, + })) + + mcp.setRequestHandler(CallToolRequestSchema, async (req) => { + const { name, arguments: args } = req.params + return bridge.handleToolCall(name, args as Record) + }) + + // Register handler for permission_request notifications from Claude Code. + mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => { + await bridge.handlePermissionRequest(params) + }) + + return mcp +} + +export async function connectMcp(mcp: Server): Promise { + await mcp.connect(new StdioServerTransport()) +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..fb8e72b --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises' +import { dirname, join } from 'node:path' + +const GatingSchema = z.object({ + mode: z.literal('per-user'), + allowedUsers: z.array(z.string()), +}) + +const SettingsSchema = z.object({ + gating: GatingSchema, + watchedChannels: z.array(z.string()), +}) + +export type Settings = z.infer + +export const DEFAULT_SETTINGS: Settings = { + gating: { mode: 'per-user', allowedUsers: [] }, + watchedChannels: [], +} + +export async function readSettings(path: string): Promise { + try { + const raw = await readFile(path, 'utf-8') + const parsed = JSON.parse(raw) + return SettingsSchema.parse(parsed) + } catch { + return { + gating: { + mode: DEFAULT_SETTINGS.gating.mode, + allowedUsers: [...DEFAULT_SETTINGS.gating.allowedUsers], + }, + watchedChannels: [...DEFAULT_SETTINGS.watchedChannels], + } + } +} + +export async function writeSettings(path: string, settings: Settings): Promise { + const dir = dirname(path) + await mkdir(dir, { recursive: true }) + const tmp = join(dir, `.settings.tmp.${process.pid}`) + await writeFile(tmp, JSON.stringify(settings, null, 2)) + await rename(tmp, path) +} diff --git a/src/slack.ts b/src/slack.ts new file mode 100644 index 0000000..bc3b1bb --- /dev/null +++ b/src/slack.ts @@ -0,0 +1,102 @@ +import { App } from '@slack/bolt' +import type { Bridge } from './bridge' + +export function createSlackApp(botToken: string, appToken: string): App { + return new App({ + token: botToken, + appToken, + socketMode: true, + }) +} + +export function registerEventHandlers(app: App, bridge: Bridge, botUserId: string): void { + // DMs and channel messages + app.event('message', async ({ event }) => { + try { + // Skip bot messages, message_changed, etc. + if ((event as any).subtype) return + + await bridge.handleMessage({ + text: (event as any).text || '', + user: (event as any).user || '', + channel: (event as any).channel || '', + channel_type: (event as any).channel_type || '', + ts: (event as any).ts || '', + thread_ts: (event as any).thread_ts, + }) + } catch (err) { + console.error('[slack-channel] error handling message:', err) + } + }) + + // @mentions + app.event('app_mention', async ({ event }) => { + try { + await bridge.handleMention({ + text: event.text || '', + user: event.user || '', + channel: event.channel || '', + ts: event.ts || '', + thread_ts: (event as any).thread_ts, + }) + } catch (err) { + console.error('[slack-channel] error handling mention:', err) + } + }) + + // Permission approval/denial buttons + app.action('permission_approve', async ({ action, body, ack }) => { + await ack() + try { + const userId = (body as any).user?.id || '' + const requestId = (action as any).value + const channelId = (body as any).channel?.id || '' + const messageTs = (body as any).message?.ts || '' + await bridge.handlePermissionAction(requestId, true, channelId, messageTs, userId) + } catch (err) { + console.error('[slack-channel] error handling permission approve:', err) + } + }) + + app.action('permission_deny', async ({ action, body, ack }) => { + await ack() + try { + const userId = (body as any).user?.id || '' + const requestId = (action as any).value + const channelId = (body as any).channel?.id || '' + const messageTs = (body as any).message?.ts || '' + await bridge.handlePermissionAction(requestId, false, channelId, messageTs, userId) + } catch (err) { + console.error('[slack-channel] error handling permission deny:', err) + } + }) + + // Reactions + app.event('reaction_added', async ({ event }) => { + try { + await bridge.handleReaction( + { + user: event.user || '', + reaction: event.reaction || '', + item: event.item as any, + item_user: (event as any).item_user || '', + event_ts: (event as any).event_ts || '', + }, + botUserId, + ) + } catch (err) { + console.error('[slack-channel] error handling reaction:', err) + } + }) +} + +export async function getBotUserId(app: App): Promise { + const authResult = await app.client.auth.test() + console.error(`[slack-channel] authenticated as ${authResult.user} (${authResult.user_id})`) + return authResult.user_id || '' +} + +export async function startSlackApp(app: App): Promise { + await app.start() + console.error('[slack-channel] Socket Mode connected') +} diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts new file mode 100644 index 0000000..eec1b9a --- /dev/null +++ b/tests/bridge.test.ts @@ -0,0 +1,538 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest' +import { Bridge, type ActiveContext } from '../src/bridge' +import { Gating } from '../src/gating' +import { DEFAULT_SETTINGS, type Settings } from '../src/settings' + +// Mock MCP server and Slack app +function createMockMcp() { + return { + notification: vi.fn(() => Promise.resolve()), + } +} + +function createMockSlackApp() { + return { + client: { + chat: { + postMessage: vi.fn(() => Promise.resolve({ ok: true })), + postEphemeral: vi.fn(() => Promise.resolve({ ok: true })), + }, + reactions: { + add: vi.fn(() => Promise.resolve({ ok: true })), + }, + users: { + info: vi.fn(() => Promise.resolve({ + ok: true, + user: { id: 'U123', name: 'alice', real_name: 'Alice' }, + })), + }, + conversations: { + info: vi.fn(() => Promise.resolve({ + ok: true, + channel: { id: 'C123', name: 'general' }, + })), + join: vi.fn(() => Promise.resolve({ ok: true })), + open: vi.fn(() => Promise.resolve({ ok: true, channel: { id: 'D_TARGET_DM' } })), + history: vi.fn(() => Promise.resolve({ + ok: true, + messages: [{ text: 'Original message text', ts: '1234.5678' }], + })), + }, + }, + } +} + +describe('Bridge - event transformation', () => { + let bridge: Bridge + let mockMcp: ReturnType + let mockSlack: ReturnType + let settings: Settings + + beforeEach(() => { + settings = { + gating: { mode: 'per-user', allowedUsers: ['U_ALLOWED'] }, + watchedChannels: ['C_WATCHED'], + } + const gating = new Gating(settings) + mockMcp = createMockMcp() + mockSlack = createMockSlackApp() + bridge = new Bridge(mockSlack as any, gating, settings) + bridge.setMcpServer(mockMcp as any) + }) + + test('emits dm event for allowed user DM', async () => { + await bridge.handleMessage({ + text: 'hello', + user: 'U_ALLOWED', + channel: 'D_DM_CHANNEL', + channel_type: 'im', + ts: '1234.5678', + }) + expect(mockMcp.notification).toHaveBeenCalledTimes(1) + const call = (mockMcp.notification as any).mock.calls[0] + expect(call[0].params.meta.event).toBe('dm') + }) + + test('drops messages from non-allowed users', async () => { + await bridge.handleMessage({ + text: 'hello', + user: 'U_STRANGER', + channel: 'D_DM_CHANNEL', + channel_type: 'im', + ts: '1234.5678', + }) + expect(mockMcp.notification).not.toHaveBeenCalled() + }) + + test('emits message event for watched channel', async () => { + await bridge.handleMessage({ + text: 'deploy failed', + user: 'U_ALLOWED', + channel: 'C_WATCHED', + channel_type: 'channel', + ts: '1234.5678', + }) + const call = (mockMcp.notification as any).mock.calls[0] + expect(call[0].params.meta.event).toBe('message') + }) + + test('drops messages from allowed users in non-watched, non-DM channels', async () => { + await bridge.handleMessage({ + text: 'hello', + user: 'U_ALLOWED', + channel: 'C_OTHER', + channel_type: 'channel', + ts: '1234.5678', + }) + expect(mockMcp.notification).not.toHaveBeenCalled() + }) + + test('updates lastActiveContext on gated events', async () => { + await bridge.handleMessage({ + text: 'hello', + user: 'U_ALLOWED', + channel: 'D_DM_CHANNEL', + channel_type: 'im', + ts: '1234.5678', + }) + expect(bridge.getLastActiveContext()).toEqual({ + userId: 'U_ALLOWED', + channelId: 'D_DM_CHANNEL', + threadTs: undefined, + }) + }) +}) + +describe('Bridge - mention events', () => { + let bridge: Bridge + let mockMcp: ReturnType + + beforeEach(() => { + const settings: Settings = { + gating: { mode: 'per-user', allowedUsers: ['U_ALLOWED'] }, + watchedChannels: [], + } + const gating = new Gating(settings) + mockMcp = createMockMcp() + const mockSlack = createMockSlackApp() + bridge = new Bridge(mockSlack as any, gating, settings) + bridge.setMcpServer(mockMcp as any) + }) + + test('emits mention event for app_mention', async () => { + await bridge.handleMention({ + text: '<@BOTID> help', + user: 'U_ALLOWED', + channel: 'C_ANY', + ts: '1234.5678', + }) + const call = (mockMcp.notification as any).mock.calls[0] + expect(call[0].params.meta.event).toBe('mention') + }) +}) + +describe('Bridge - permission verdict parsing', () => { + test('parses yes verdict', () => { + expect(Bridge.parsePermissionVerdict('yes abcde')).toEqual({ + requestId: 'abcde', + behavior: 'allow', + }) + }) + + test('parses no verdict', () => { + expect(Bridge.parsePermissionVerdict('no abcde')).toEqual({ + requestId: 'abcde', + behavior: 'deny', + }) + }) + + test('parses y shorthand', () => { + expect(Bridge.parsePermissionVerdict('y fghkm')).toEqual({ + requestId: 'fghkm', + behavior: 'allow', + }) + }) + + test('handles case insensitivity', () => { + expect(Bridge.parsePermissionVerdict('YES ABCDE')).toEqual({ + requestId: 'abcde', + behavior: 'allow', + }) + }) + + test('returns null for non-verdict text', () => { + expect(Bridge.parsePermissionVerdict('hello world')).toBeNull() + }) + + test('returns null for verdict with l in id', () => { + expect(Bridge.parsePermissionVerdict('yes ablde')).toBeNull() + }) + + test('parses verdict with digits in id', () => { + expect(Bridge.parsePermissionVerdict('yes a2c3e')).toEqual({ + requestId: 'a2c3e', + behavior: 'allow', + }) + }) +}) + +describe('Bridge - tool authorization', () => { + let bridge: Bridge + let mockMcp: ReturnType + let mockSlack: ReturnType + + beforeEach(() => { + const settings: Settings = { + gating: { mode: 'per-user', allowedUsers: ['U_ADMIN'] }, + watchedChannels: [], + } + const gating = new Gating(settings) + mockMcp = createMockMcp() + mockSlack = createMockSlackApp() + bridge = new Bridge(mockSlack as any, gating, settings) + bridge.setMcpServer(mockMcp as any) + }) + + test('manage_access fails when lastActiveContext is null', async () => { + const result = await bridge.handleToolCall('manage_access', { + action: 'add_user', + value: 'U_NEW', + }) + expect(result.content[0].text).toContain('authorization') + }) + + test('manage_access fails when caller not in allowlist', async () => { + // Set lastActiveContext to a non-allowed user (simulate somehow) + // Actually this shouldn't happen since context is only set for gated users + // Test the null case is sufficient + const result = await bridge.handleToolCall('manage_access', { + action: 'add_user', + value: 'U_NEW', + }) + expect(result.content[0].text).toContain('authorization') + }) + + test('manage_channels fails when lastActiveContext is null', async () => { + const result = await bridge.handleToolCall('manage_channels', { + action: 'watch', + channel_id: 'C_NEW', + }) + expect(result.content[0].text).toContain('authorization') + }) + + test('reply fails when lastActiveContext is null', async () => { + const result = await bridge.handleToolCall('reply', { + channel_id: 'C123', + text: 'hello', + }) + expect(result.content[0].text).toContain('authorization') + }) + + test('react fails when lastActiveContext is null', async () => { + const result = await bridge.handleToolCall('react', { + channel_id: 'C123', + timestamp: '1234.5678', + emoji: 'thumbsup', + }) + expect(result.content[0].text).toContain('authorization') + }) +}) + +describe('Bridge - name resolution cache', () => { + let bridge: Bridge + let mockSlack: ReturnType + + beforeEach(() => { + const settings: Settings = { + gating: { mode: 'per-user', allowedUsers: ['U_ALLOWED'] }, + watchedChannels: ['C123'], + } + const gating = new Gating(settings) + const mockMcp = createMockMcp() + mockSlack = createMockSlackApp() + bridge = new Bridge(mockSlack as any, gating, settings) + bridge.setMcpServer(mockMcp as any) + }) + + test('resolves and caches user name', async () => { + const name1 = await bridge.resolveUserName('U123') + const name2 = await bridge.resolveUserName('U123') + expect(name1).toBe('Alice') + expect(name2).toBe('Alice') + // Should only have called the API once due to caching + expect(mockSlack.client.users.info).toHaveBeenCalledTimes(1) + }) + + test('resolves and caches channel name', async () => { + const name1 = await bridge.resolveChannelName('C123') + const name2 = await bridge.resolveChannelName('C123') + expect(name1).toBe('general') + expect(name2).toBe('general') + expect(mockSlack.client.conversations.info).toHaveBeenCalledTimes(1) + }) +}) + +describe('Bridge - reaction handling', () => { + let bridge: Bridge + let mockMcp: ReturnType + let mockSlack: ReturnType + + beforeEach(() => { + const settings: Settings = { + gating: { mode: 'per-user', allowedUsers: ['U_ALLOWED'] }, + watchedChannels: [], + } + const gating = new Gating(settings) + mockMcp = createMockMcp() + mockSlack = createMockSlackApp() + bridge = new Bridge(mockSlack as any, gating, settings) + bridge.setMcpServer(mockMcp as any) + }) + + test('emits reaction notification for bot message', async () => { + await bridge.handleReaction({ + user: 'U_ALLOWED', + reaction: 'eyes', + item: { type: 'message', channel: 'C123', ts: '1234.5678' }, + item_user: 'U_BOT', + event_ts: '1234.9999', + }, 'U_BOT') + expect(mockMcp.notification).toHaveBeenCalledTimes(1) + const call = (mockMcp.notification as any).mock.calls[0][0] + expect(call.params.meta.event).toBe('reaction') + expect(call.params.meta.source).toBe('slack') + expect(call.params.meta.emoji).toBe('eyes') + expect(call.params.content).toContain('Original message text') + }) + + test('drops reactions on non-bot messages', async () => { + await bridge.handleReaction({ + user: 'U_ALLOWED', + reaction: 'eyes', + item: { type: 'message', channel: 'C123', ts: '1234.5678' }, + item_user: 'U_OTHER', + event_ts: '1234.9999', + }, 'U_BOT') + expect(mockMcp.notification).not.toHaveBeenCalled() + }) + + test('drops reactions on non-message items', async () => { + await bridge.handleReaction({ + user: 'U_ALLOWED', + reaction: 'eyes', + item: { type: 'file', channel: 'C123', ts: '1234.5678' }, + item_user: 'U_BOT', + event_ts: '1234.9999', + }, 'U_BOT') + expect(mockMcp.notification).not.toHaveBeenCalled() + }) + + test('drops reactions from non-allowed users', async () => { + await bridge.handleReaction({ + user: 'U_STRANGER', + reaction: 'eyes', + item: { type: 'message', channel: 'C123', ts: '1234.5678' }, + item_user: 'U_BOT', + event_ts: '1234.9999', + }, 'U_BOT') + expect(mockMcp.notification).not.toHaveBeenCalled() + }) +}) + +describe('Bridge - mention gating', () => { + test('drops mentions from non-allowed users', async () => { + const settings: Settings = { + gating: { mode: 'per-user', allowedUsers: ['U_ALLOWED'] }, + watchedChannels: [], + } + const gating = new Gating(settings) + const mockMcp = createMockMcp() + const mockSlack = createMockSlackApp() + const bridge = new Bridge(mockSlack as any, gating, settings) + bridge.setMcpServer(mockMcp as any) + + await bridge.handleMention({ + text: '<@BOT> help', + user: 'U_STRANGER', + channel: 'C_ANY', + ts: '1234.5678', + }) + expect(mockMcp.notification).not.toHaveBeenCalled() + }) +}) + +describe('Bridge - source attribute', () => { + test('notifications include source: slack in meta', async () => { + const settings: Settings = { + gating: { mode: 'per-user', allowedUsers: ['U_ALLOWED'] }, + watchedChannels: [], + } + const gating = new Gating(settings) + const mockMcp = createMockMcp() + const mockSlack = createMockSlackApp() + const bridge = new Bridge(mockSlack as any, gating, settings) + bridge.setMcpServer(mockMcp as any) + + await bridge.handleMessage({ + text: 'hello', + user: 'U_ALLOWED', + channel: 'D_DM', + channel_type: 'im', + ts: '1234.5678', + }) + const call = (mockMcp.notification as any).mock.calls[0][0] + expect(call.params.meta.source).toBe('slack') + }) +}) + +describe('Bridge - manage_access success paths', () => { + let bridge: Bridge + let mockSlack: ReturnType + let gating: Gating + + beforeEach(async () => { + const settings: Settings = { + gating: { mode: 'per-user', allowedUsers: ['U_ADMIN'] }, + watchedChannels: [], + } + gating = new Gating(settings) + const mockMcp = createMockMcp() + mockSlack = createMockSlackApp() + bridge = new Bridge(mockSlack as any, gating, settings) + bridge.setMcpServer(mockMcp as any) + + // Set up active context + await bridge.handleMessage({ + text: 'setup', + user: 'U_ADMIN', + channel: 'D_ADMIN_DM', + channel_type: 'im', + ts: '100.001', + }) + }) + + test('add_user adds to allowlist', async () => { + const result = await bridge.handleToolCall('manage_access', { + action: 'add_user', + value: 'U_NEW', + }) + expect(result.content[0].text).toContain('added') + expect(gating.isAllowed('U_NEW')).toBe(true) + }) + + test('remove_user removes from allowlist', async () => { + gating.addUser('U_TEMP') + const result = await bridge.handleToolCall('manage_access', { + action: 'remove_user', + value: 'U_TEMP', + }) + expect(result.content[0].text).toContain('removed') + expect(gating.isAllowed('U_TEMP')).toBe(false) + }) + + test('pair_user sends code to target DM', async () => { + const result = await bridge.handleToolCall('manage_access', { + action: 'pair_user', + value: 'U_TARGET', + }) + expect(result.content[0].text).toContain('pairing code sent') + expect(mockSlack.client.conversations.open).toHaveBeenCalledTimes(1) + const openCall = (mockSlack.client.conversations.open as any).mock.calls[0][0] + expect(openCall.users).toBe('U_TARGET') + expect(mockSlack.client.chat.postEphemeral).toHaveBeenCalledTimes(1) + const ephCall = (mockSlack.client.chat.postEphemeral as any).mock.calls[0][0] + expect(ephCall.channel).toBe('D_TARGET_DM') + expect(ephCall.user).toBe('U_TARGET') + }) +}) + +describe('Bridge - manage_channels success paths', () => { + let bridge: Bridge + let mockSlack: ReturnType + let settings: Settings + + beforeEach(async () => { + settings = { + gating: { mode: 'per-user', allowedUsers: ['U_ADMIN'] }, + watchedChannels: [], + } + const gating = new Gating(settings) + const mockMcp = createMockMcp() + mockSlack = createMockSlackApp() + bridge = new Bridge(mockSlack as any, gating, settings) + bridge.setMcpServer(mockMcp as any) + + await bridge.handleMessage({ + text: 'setup', + user: 'U_ADMIN', + channel: 'D_ADMIN_DM', + channel_type: 'im', + ts: '100.001', + }) + }) + + test('watch joins channel then adds to watchedChannels', async () => { + const result = await bridge.handleToolCall('manage_channels', { + action: 'watch', + channel_id: 'C_NEW', + }) + expect(result.content[0].text).toContain('watching') + expect(mockSlack.client.conversations.join).toHaveBeenCalledTimes(1) + expect(settings.watchedChannels).toContain('C_NEW') + }) + + test('unwatch removes from watchedChannels', async () => { + // First watch it + await bridge.handleToolCall('manage_channels', { + action: 'watch', + channel_id: 'C_NEW', + }) + const result = await bridge.handleToolCall('manage_channels', { + action: 'unwatch', + channel_id: 'C_NEW', + }) + expect(result.content[0].text).toContain('stopped watching') + expect(settings.watchedChannels).not.toContain('C_NEW') + }) +}) + +describe('Bridge - bootstrap DM restriction', () => { + test('ignores bootstrap messages from non-DM channels', async () => { + const gating = new Gating(DEFAULT_SETTINGS) + const mockMcp = createMockMcp() + const mockSlack = createMockSlackApp() + const bridge = new Bridge(mockSlack as any, gating, DEFAULT_SETTINGS) + bridge.setMcpServer(mockMcp as any) + + await bridge.handleMessage({ + text: 'hello', + user: 'U_NEW', + channel: 'C_PUBLIC', + channel_type: 'channel', + ts: '100.001', + }) + // Should not send any ephemeral or notification + expect(mockSlack.client.chat.postEphemeral).not.toHaveBeenCalled() + expect(mockMcp.notification).not.toHaveBeenCalled() + }) +}) diff --git a/tests/gating.test.ts b/tests/gating.test.ts new file mode 100644 index 0000000..c674749 --- /dev/null +++ b/tests/gating.test.ts @@ -0,0 +1,105 @@ +import { describe, test, expect, beforeEach } from 'vitest' +import { Gating } from '../src/gating' +import { DEFAULT_SETTINGS, type Settings } from '../src/settings' + +describe('Gating', () => { + let settings: Settings + let gating: Gating + + beforeEach(() => { + settings = { + gating: { mode: 'per-user', allowedUsers: ['U_ALLOWED'] }, + watchedChannels: [], + } + gating = new Gating(settings) + }) + + test('allows users in the allowlist', () => { + expect(gating.isAllowed('U_ALLOWED')).toBe(true) + }) + + test('rejects users not in the allowlist', () => { + expect(gating.isAllowed('U_STRANGER')).toBe(false) + }) + + test('detects bootstrap mode when allowlist is empty', () => { + gating = new Gating(DEFAULT_SETTINGS) + expect(gating.isBootstrapMode()).toBe(true) + }) + + test('not in bootstrap mode when allowlist has users', () => { + expect(gating.isBootstrapMode()).toBe(false) + }) + + test('addUser adds to allowlist and exits bootstrap', () => { + gating = new Gating(DEFAULT_SETTINGS) + expect(gating.isBootstrapMode()).toBe(true) + gating.addUser('U_NEW') + expect(gating.isAllowed('U_NEW')).toBe(true) + expect(gating.isBootstrapMode()).toBe(false) + }) + + test('removeUser removes from allowlist', () => { + gating.removeUser('U_ALLOWED') + expect(gating.isAllowed('U_ALLOWED')).toBe(false) + }) +}) + +describe('Pairing', () => { + let gating: Gating + + beforeEach(() => { + gating = new Gating(DEFAULT_SETTINGS) + }) + + test('generates a 6-character alphanumeric code', () => { + const code = gating.createPairingCode('U_TARGET') + expect(code).toMatch(/^[A-Z0-9]{6}$/) + }) + + test('verifies a valid code for the correct user', () => { + const code = gating.createPairingCode('U_TARGET') + expect(gating.verifyPairingCode(code!, 'U_TARGET')).toBe(true) + }) + + test('rejects a valid code for the wrong user', () => { + const code = gating.createPairingCode('U_TARGET') + expect(gating.verifyPairingCode(code!, 'U_OTHER')).toBe(false) + }) + + test('rejects an invalid code', () => { + gating.createPairingCode('U_TARGET') + expect(gating.verifyPairingCode('ZZZZZZ', 'U_TARGET')).toBe(false) + }) + + test('code is consumed after verification', () => { + const code = gating.createPairingCode('U_TARGET') + expect(gating.verifyPairingCode(code!, 'U_TARGET')).toBe(true) + expect(gating.verifyPairingCode(code!, 'U_TARGET')).toBe(false) + }) + + test('expired codes are rejected', () => { + const code = gating.createPairingCode('U_TARGET', Date.now() - 6 * 60 * 1000) + expect(gating.verifyPairingCode(code!, 'U_TARGET')).toBe(false) + }) + + test('only one code active during bootstrap', () => { + const code1 = gating.createPairingCode('U_FIRST') + const code2 = gating.createPairingCode('U_SECOND') + expect(code2).toBeNull() + expect(gating.verifyPairingCode(code1!, 'U_FIRST')).toBe(true) + }) + + test('hasPendingPairingCode returns true when code is active', () => { + gating.createPairingCode('U_TARGET') + expect(gating.hasPendingPairingCode()).toBe(true) + }) + + test('multiple codes allowed when not in bootstrap mode', () => { + gating.addUser('U_ADMIN') + const code1 = gating.createPairingCode('U_FIRST') + const code2 = gating.createPairingCode('U_SECOND') + expect(code1).not.toBeNull() + expect(code2).not.toBeNull() + }) +}) diff --git a/tests/integration.test.ts b/tests/integration.test.ts new file mode 100644 index 0000000..f39acda --- /dev/null +++ b/tests/integration.test.ts @@ -0,0 +1,154 @@ +import { describe, test, expect, vi } from 'vitest' +import { Gating } from '../src/gating' +import { Bridge } from '../src/bridge' +import { TOOL_DEFINITIONS } from '../src/mcp' +import { DEFAULT_SETTINGS } from '../src/settings' + +function createMocks() { + const mcp = { notification: vi.fn(() => Promise.resolve()) } + const slack = { + client: { + chat: { + postMessage: vi.fn(() => Promise.resolve({ ok: true })), + postEphemeral: vi.fn(() => Promise.resolve({ ok: true })), + }, + reactions: { add: vi.fn(() => Promise.resolve({ ok: true })) }, + users: { + info: vi.fn(() => + Promise.resolve({ ok: true, user: { id: 'U1', name: 'alice', real_name: 'Alice' } }) + ), + }, + conversations: { + info: vi.fn(() => + Promise.resolve({ ok: true, channel: { id: 'C1', name: 'general' } }) + ), + join: vi.fn(() => Promise.resolve({ ok: true })), + open: vi.fn(() => Promise.resolve({ ok: true, channel: { id: 'D_DM' } })), + history: vi.fn(() => Promise.resolve({ ok: true, messages: [] })), + }, + }, + } + return { mcp, slack } +} + +describe('End-to-end flow', () => { + test('DM → notification → reply tool → Slack message', async () => { + const settings = { + gating: { mode: 'per-user' as const, allowedUsers: ['U_ALICE'] }, + watchedChannels: [], + } + const gating = new Gating(settings) + const { mcp, slack } = createMocks() + const bridge = new Bridge(slack as any, gating, settings) + bridge.setMcpServer(mcp as any) + + // Alice sends a DM + await bridge.handleMessage({ + text: 'check the deploy', + user: 'U_ALICE', + channel: 'D_ALICE_DM', + channel_type: 'im', + ts: '100.001', + }) + + // Notification emitted + expect(mcp.notification).toHaveBeenCalledTimes(1) + const notif = (mcp.notification as any).mock.calls[0][0] + expect(notif.params.meta.event).toBe('dm') + expect(notif.params.content).toBe('check the deploy') + + // Claude replies via tool + const result = await bridge.handleToolCall('reply', { + channel_id: 'D_ALICE_DM', + text: 'Deploy looks good', + }) + expect(result.content[0].text).toBe('sent') + expect(slack.client.chat.postMessage).toHaveBeenCalledTimes(1) + }) + + test('permission relay full cycle', async () => { + const settings = { + gating: { mode: 'per-user' as const, allowedUsers: ['U_ALICE'] }, + watchedChannels: [], + } + const gating = new Gating(settings) + const { mcp, slack } = createMocks() + const bridge = new Bridge(slack as any, gating, settings) + bridge.setMcpServer(mcp as any) + + // Set up active context via a DM + await bridge.handleMessage({ + text: 'do something', + user: 'U_ALICE', + channel: 'D_ALICE_DM', + channel_type: 'im', + ts: '100.001', + }) + + // Permission request arrives + await bridge.handlePermissionRequest({ + request_id: 'abcde', + tool_name: 'Bash', + description: 'git pull origin main', + input_preview: '{"command":"git pull origin main"}', + }) + + // Should have posted to Alice's DM + expect(slack.client.chat.postMessage).toHaveBeenCalledTimes(1) + const msg = (slack.client.chat.postMessage as any).mock.calls[0][0] + expect(msg.text).toContain('abcde') + expect(msg.channel).toBe('D_ALICE_DM') + + // Alice replies with approval + await bridge.handleMessage({ + text: 'yes abcde', + user: 'U_ALICE', + channel: 'D_ALICE_DM', + channel_type: 'im', + ts: '100.002', + }) + + // Verdict notification emitted (the second call — first was the DM notification) + const verdictCall = (mcp.notification as any).mock.calls[1] + expect(verdictCall[0].params.request_id).toBe('abcde') + expect(verdictCall[0].params.behavior).toBe('allow') + }) + + test('bootstrap pairing flow', async () => { + const settings = { ...DEFAULT_SETTINGS } + const gating = new Gating(settings) + const { mcp, slack } = createMocks() + const bridge = new Bridge(slack as any, gating, settings) + bridge.setMcpServer(mcp as any) + + // User DMs the bot in bootstrap mode + await bridge.handleMessage({ + text: 'hello', + user: 'U_NEW', + channel: 'D_DM', + channel_type: 'im', + ts: '100.001', + }) + + // Should have sent ephemeral with pairing code + expect(slack.client.chat.postEphemeral).toHaveBeenCalledTimes(1) + const ephemeral = (slack.client.chat.postEphemeral as any).mock.calls[0][0] + const codeMatch = ephemeral.text.match(/`([A-Z0-9]{6})`/) + expect(codeMatch).not.toBeNull() + + const code = codeMatch![1] + + // User echoes the code back + await bridge.handleMessage({ + text: `pair ${code}`, + user: 'U_NEW', + channel: 'D_DM', + channel_type: 'im', + ts: '100.002', + }) + + // User should now be allowed + expect(gating.isAllowed('U_NEW')).toBe(true) + expect(gating.isBootstrapMode()).toBe(false) + }) +}) diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts new file mode 100644 index 0000000..d706fd6 --- /dev/null +++ b/tests/mcp.test.ts @@ -0,0 +1,45 @@ +import { describe, test, expect } from 'vitest' +import { TOOL_DEFINITIONS, CHANNEL_INSTRUCTIONS } from '../src/mcp' + +describe('MCP tool definitions', () => { + test('defines reply tool with required params', () => { + const reply = TOOL_DEFINITIONS.find(t => t.name === 'reply') + expect(reply).toBeDefined() + expect(reply!.inputSchema.required).toContain('channel_id') + expect(reply!.inputSchema.required).toContain('text') + expect(reply!.inputSchema.properties).toHaveProperty('thread_ts') + }) + + test('defines react tool with required params', () => { + const react = TOOL_DEFINITIONS.find(t => t.name === 'react') + expect(react).toBeDefined() + expect(react!.inputSchema.required).toContain('channel_id') + expect(react!.inputSchema.required).toContain('timestamp') + expect(react!.inputSchema.required).toContain('emoji') + }) + + test('defines manage_access tool with required params', () => { + const tool = TOOL_DEFINITIONS.find(t => t.name === 'manage_access') + expect(tool).toBeDefined() + expect(tool!.inputSchema.required).toContain('action') + expect(tool!.inputSchema.required).toContain('value') + }) + + test('defines manage_channels tool with required params', () => { + const tool = TOOL_DEFINITIONS.find(t => t.name === 'manage_channels') + expect(tool).toBeDefined() + expect(tool!.inputSchema.required).toContain('action') + expect(tool!.inputSchema.required).toContain('channel_id') + }) + + test('exports exactly 4 tools', () => { + expect(TOOL_DEFINITIONS).toHaveLength(4) + }) + + test('instructions mention all event types', () => { + expect(CHANNEL_INSTRUCTIONS).toContain('dm') + expect(CHANNEL_INSTRUCTIONS).toContain('mention') + expect(CHANNEL_INSTRUCTIONS).toContain('message') + expect(CHANNEL_INSTRUCTIONS).toContain('reaction') + }) +}) diff --git a/tests/settings.test.ts b/tests/settings.test.ts new file mode 100644 index 0000000..88a51df --- /dev/null +++ b/tests/settings.test.ts @@ -0,0 +1,78 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest' +import { readSettings, writeSettings, DEFAULT_SETTINGS, type Settings } from '../src/settings' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +describe('readSettings', () => { + let dir: string + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'settings-test-')) + }) + + afterEach(async () => { + await rm(dir, { recursive: true }) + }) + + test('returns defaults when file does not exist', async () => { + const settings = await readSettings(join(dir, 'settings.json')) + expect(settings).toEqual(DEFAULT_SETTINGS) + }) + + test('reads valid settings file', async () => { + const path = join(dir, 'settings.json') + const data: Settings = { + gating: { mode: 'per-user', allowedUsers: ['U123'] }, + watchedChannels: ['C456'], + } + await writeFile(path, JSON.stringify(data)) + const settings = await readSettings(path) + expect(settings.gating.allowedUsers).toEqual(['U123']) + expect(settings.watchedChannels).toEqual(['C456']) + }) + + test('returns defaults on corrupted JSON', async () => { + const path = join(dir, 'settings.json') + await writeFile(path, 'not valid json{{{') + const settings = await readSettings(path) + expect(settings).toEqual(DEFAULT_SETTINGS) + }) + + test('returns defaults on invalid schema', async () => { + const path = join(dir, 'settings.json') + await writeFile(path, JSON.stringify({ gating: { mode: 'invalid' } })) + const settings = await readSettings(path) + expect(settings).toEqual(DEFAULT_SETTINGS) + }) +}) + +describe('writeSettings', () => { + let dir: string + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'settings-test-')) + }) + + afterEach(async () => { + await rm(dir, { recursive: true }) + }) + + test('writes and reads back settings', async () => { + const path = join(dir, 'settings.json') + const data: Settings = { + gating: { mode: 'per-user', allowedUsers: ['UABC'] }, + watchedChannels: ['CDEF'], + } + await writeSettings(path, data) + const result = await readSettings(path) + expect(result).toEqual(data) + }) + + test('creates parent directories if missing', async () => { + const path = join(dir, 'nested', 'deep', 'settings.json') + await writeSettings(path, DEFAULT_SETTINGS) + const result = await readSettings(path) + expect(result).toEqual(DEFAULT_SETTINGS) + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3e1a3ed --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..f964be2 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + }, +})