From 7ac455342b1779387ff6f1360c19524403cf3531 Mon Sep 17 00:00:00 2001 From: Mukul yadav Date: Thu, 26 Feb 2026 18:57:23 +0530 Subject: [PATCH 1/5] feat: spotify skill --- .claude/skills/add-keys/SKILL.md | 1 + .claude/skills/add-keys/spotify/SKILL.md | 158 +++++++++++++++++++++++ package.json | 2 +- pnpm-lock.yaml | 10 +- server/corsair.ts | 4 +- server/index.ts | 95 ++++++++++++++ 6 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 .claude/skills/add-keys/spotify/SKILL.md diff --git a/.claude/skills/add-keys/SKILL.md b/.claude/skills/add-keys/SKILL.md index 5e8ef8d..c37b963 100644 --- a/.claude/skills/add-keys/SKILL.md +++ b/.claude/skills/add-keys/SKILL.md @@ -216,3 +216,4 @@ Each plugin has its own skill with the exact script to run: | Discord | `api_key` | `/add-keys/discord` | | Google Calendar | `oauth_2` | `/add-keys/google` | | Google Drive | `oauth_2` | `/add-keys/google` (shares credentials with Calendar) | +| Spotify | `oauth_2` | `/add-keys/spotify` | diff --git a/.claude/skills/add-keys/spotify/SKILL.md b/.claude/skills/add-keys/spotify/SKILL.md new file mode 100644 index 0000000..c8714e4 --- /dev/null +++ b/.claude/skills/add-keys/spotify/SKILL.md @@ -0,0 +1,158 @@ +--- +name: add-keys/spotify +description: Set up Spotify OAuth credentials for Corsair. Use when the user wants to connect Spotify to their agent. +--- + +# Spotify Key Setup + +Read `/add-keys` first if you haven't — it explains the key model. + +Auth type: **`oauth_2`** +- Integration level: `client_id`, `client_secret`, `redirect_url` (your Spotify app — shared) +- Account level: `access_token`, `refresh_token`, `expires_at`, `scope` (per-tenant) + +--- + +## 1. Create a Spotify app + +1. Go to https://developer.spotify.com/dashboard +2. Log in and click **Create app** +3. Fill in: + - **App name**: Corsair (or anything you like) + - **App description**: (any) + - **Redirect URI**: `http://localhost:3000/oauth/callback` + - **APIs used**: check **Web API** +4. Agree to the terms → **Save** +5. Open the app → **Settings** +6. Copy the **Client ID** and **Client secret** + +--- + +## 2. Write and run the setup script + +Ask the user to provide the Client ID and Client Secret, then write `scripts/setup-spotify.ts`: + +```typescript +import 'dotenv/config'; +import { and, eq } from 'drizzle-orm'; +import { corsair } from '../server/corsair'; +import { db } from '../server/db'; +import { corsairAccounts, corsairIntegrations } from '../server/db/schema'; + +const PLUGIN = 'spotify'; +const TENANT_ID = 'default'; +const REDIRECT_URL = 'http://localhost:3000/oauth/callback'; + +// ── credentials (fill these in) ─────────────────────────────────────────────── +const CLIENT_ID = '...'; +const CLIENT_SECRET = '...'; +// ───────────────────────────────────────────────────────────────────────────── + +async function main() { + console.log('\nSetting up Spotify...'); + + // 1. Ensure integration row exists + let [integration] = await db + .select() + .from(corsairIntegrations) + .where(eq(corsairIntegrations.name, PLUGIN)); + + if (!integration) { + [integration] = await db + .insert(corsairIntegrations) + .values({ id: crypto.randomUUID(), name: PLUGIN }) + .returning(); + console.log(' ✓ Created integration'); + } + + // 2. Issue integration DEK and store OAuth app credentials + await corsair.keys.spotify.issue_new_dek(); + await corsair.keys.spotify.set_client_id(CLIENT_ID); + await corsair.keys.spotify.set_client_secret(CLIENT_SECRET); + await corsair.keys.spotify.set_redirect_url(REDIRECT_URL); + console.log(' ✓ Integration credentials stored'); + + // 3. Ensure account row exists + const [existing] = await db + .select() + .from(corsairAccounts) + .where( + and( + eq(corsairAccounts.tenantId, TENANT_ID), + eq(corsairAccounts.integrationId, integration!.id), + ), + ); + + if (!existing) { + await db.insert(corsairAccounts).values({ + id: crypto.randomUUID(), + tenantId: TENANT_ID, + integrationId: integration!.id, + }); + console.log(' ✓ Created account'); + } + + // 4. Issue account DEK (tokens come from OAuth flow) + await corsair.spotify.keys.issue_new_dek(); + console.log(' ✓ Account DEK ready'); + + console.log('\n✓ Credentials stored. Now complete OAuth at http://localhost:3000/oauth/spotify'); + process.exit(0); +} + +main().catch((e) => { console.error(e); process.exit(1); }); +``` + +Run it: + +```bash +docker compose exec agent pnpm tsx scripts/setup-spotify.ts +``` + +Then delete the script: + +```bash +rm scripts/setup-spotify.ts +``` + +--- + +## 3. Complete the OAuth flow + +Tell the user to open: + +``` +http://localhost:3000/oauth/spotify +``` + +This will: +1. Redirect them to Spotify's authorization page +2. After they click **Agree**, redirect back to `/oauth/callback` +3. Automatically exchange the code for tokens and store `access_token`, `refresh_token`, `expires_at`, and `scope` +4. Show a success page + +No copying tokens manually — the server handles everything. + +--- + +## 4. Verify + +```bash +docker compose exec agent pnpm tsx -e " +import 'dotenv/config'; +import { corsair } from './server/corsair'; +corsair.spotify.keys.get_access_token().then(k => console.log('token:', k?.slice(0,8) + '...')).then(() => process.exit(0)); +" +``` + +No restart needed — the agent reads from the DB on every request. + +--- + +## Notes + +**Token refresh:** The plugin calls `getValidAccessToken()` internally on every request, using the stored `refresh_token` + `client_id` + `client_secret` to get a fresh access token automatically. + +**Re-authorizing:** If tokens expire or are revoked, just visit `http://localhost:3000/oauth/spotify` again. + +**Scopes:** Spotify scopes are requested during the OAuth flow. If you need additional scopes (e.g. `playlist-modify-public`, `user-read-playback-state`), re-authorize at the OAuth URL above. diff --git a/package.json b/package.json index 2328584..b2d128f 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@trpc/server": "^11.10.0", "@whiskeysockets/baileys": "^7.0.0-rc.9", "chokidar": "^4.0.3", - "corsair": "0.1.13", + "corsair": "0.1.16", "dotenv": "^16.4.0", "drizzle-orm": "^0.44.5", "express": "^4.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10c4f27..6c1cad8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^4.0.3 version: 4.0.3 corsair: - specifier: 0.1.13 - version: 0.1.13 + specifier: 0.1.16 + version: 0.1.16 dotenv: specifier: ^16.4.0 version: 16.6.1 @@ -1525,8 +1525,8 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - corsair@0.1.13: - resolution: {integrity: sha512-BcTK4DuPzNXbhYZ3Qu6b1BIH0dO1+VTScz2m/1BQs3Et35IIQv9uMJhI24Ij07kN4IPJ7YDnS37kFkET4fry9Q==} + corsair@0.1.16: + resolution: {integrity: sha512-RDhlhEiO6DSnoEqamz/p3f6tzNpHPlxtOWDu9ukAFNTJr4gPct9hpq2yfpgfuAed67TKvIJU86kGl7vHu8rWew==} cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} @@ -4432,7 +4432,7 @@ snapshots: cookie@0.7.2: {} - corsair@0.1.13: + corsair@0.1.16: dependencies: kysely: 0.28.11 uuid: 13.0.0 diff --git a/server/corsair.ts b/server/corsair.ts index d91acf4..16325c7 100644 --- a/server/corsair.ts +++ b/server/corsair.ts @@ -1,8 +1,8 @@ -import { createCorsair, slack } from 'corsair'; +import { createCorsair, slack, spotify } from 'corsair'; import { pool } from './db'; export const corsair = createCorsair({ - plugins: [slack()], + plugins: [slack(), spotify()], database: pool, kek: process.env.CORSAIR_KEK!, multiTenancy: false, diff --git a/server/index.ts b/server/index.ts index bdf33cf..f68edfe 100644 --- a/server/index.ts +++ b/server/index.ts @@ -244,6 +244,42 @@ async function main() { app.get('/oauth/googlecalendar', (req, res) => startGoogleOAuth('googlecalendar', res)); app.get('/oauth/googledrive', (req, res) => startGoogleOAuth('googledrive', res)); + // ── Spotify OAuth flow ──────────────────────────────────────────────────── + + app.get('/oauth/spotify', async (req, res) => { + try { + const creds = await corsair.spotify.keys.get_integration_credentials(); + if (!creds.client_id || !creds.redirect_url) { + res.status(400).send('Spotify not configured. Run the setup script first.'); + return; + } + const url = new URL('https://accounts.spotify.com/authorize'); + url.searchParams.set('client_id', creds.client_id); + url.searchParams.set('redirect_uri', creds.redirect_url); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', [ + 'user-read-playback-state', + 'user-modify-playback-state', + 'user-read-currently-playing', + 'playlist-read-private', + 'playlist-read-collaborative', + 'playlist-modify-public', + 'playlist-modify-private', + 'user-library-read', + 'user-library-modify', + 'user-read-private', + 'user-read-email', + 'user-top-read', + ].join(' ')); + url.searchParams.set('state', 'spotify'); + console.log('[oauth] Redirecting to Spotify consent screen'); + res.redirect(url.toString()); + } catch (err) { + console.error('[oauth] Failed to build Spotify auth URL:', err); + res.status(500).send('OAuth setup error — check server logs.'); + } + }); + app.get('/oauth/callback', async (req, res) => { const { code, error, state } = req.query as { code?: string; error?: string; state?: string }; @@ -256,6 +292,65 @@ async function main() { return; } + // ── Spotify callback ────────────────────────────────────────────────── + if (state === 'spotify') { + try { + const pluginKeys = corsair.spotify.keys; + const creds = await pluginKeys.get_integration_credentials(); + if (!creds.client_id || !creds.client_secret || !creds.redirect_url) { + res.status(400).send('Missing Spotify credentials.'); + return; + } + const basic = Buffer.from(`${creds.client_id}:${creds.client_secret}`).toString('base64'); + const tokenRes = await fetch('https://accounts.spotify.com/api/token', { + method: 'POST', + headers: { + Authorization: `Basic ${basic}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + code, + redirect_uri: creds.redirect_url, + grant_type: 'authorization_code', + }), + }); + if (!tokenRes.ok) { + const err = await tokenRes.text(); + console.error('[oauth] Spotify token exchange failed:', err); + res.status(500).send(`Token exchange failed: ${err}`); + return; + } + const tokens = await tokenRes.json() as { + access_token: string; + refresh_token?: string; + expires_in: number; + scope: string; + }; + await pluginKeys.set_access_token(tokens.access_token); + if (tokens.refresh_token) await pluginKeys.set_refresh_token(tokens.refresh_token); + await pluginKeys.set_expires_at(new Date(Date.now() + tokens.expires_in * 1000).toISOString()); + await pluginKeys.set_scope(tokens.scope); + console.log('[oauth] Spotify tokens stored successfully'); + res.setHeader('Content-Type', 'text/html').send(` + + + Spotify Connected + +
+
+

Spotify connected!

+

You can close this tab. Your agent is ready to use Spotify.

+
+ + `); + } catch (err) { + console.error('[oauth] Spotify callback error:', err); + res.status(500).send('OAuth callback error — check server logs.'); + } + return; + } + + // ── Google callback ─────────────────────────────────────────────────── const plugin = (state && state in GOOGLE_PLUGIN_CONFIG) ? (state as keyof typeof GOOGLE_PLUGIN_CONFIG) : 'googlecalendar'; From f94d1c3766c96b0f2f1e319e0b2ead451b8f1909 Mon Sep 17 00:00:00 2001 From: Mukul yadav Date: Thu, 26 Feb 2026 19:21:55 +0530 Subject: [PATCH 2/5] feat: gmail skill add --- .claude/skills/add-keys/google/SKILL.md | 36 ++++++++++++++++--------- server/index.ts | 5 ++++ 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/.claude/skills/add-keys/google/SKILL.md b/.claude/skills/add-keys/google/SKILL.md index 1e061ce..368f10f 100644 --- a/.claude/skills/add-keys/google/SKILL.md +++ b/.claude/skills/add-keys/google/SKILL.md @@ -1,17 +1,17 @@ --- name: add-keys/google -description: Set up Google OAuth credentials for Corsair. Use when the user wants to connect Google Calendar or Google Drive. Both plugins share the same OAuth app and credentials. +description: Set up Google OAuth credentials for Corsair. Use when the user wants to connect Google Calendar, Google Drive, or Gmail. All three plugins share the same OAuth app and credentials. --- -# Google Key Setup (Calendar + Drive) +# Google Key Setup (Calendar + Drive + Gmail) Read `/add-keys` first if you haven't — it explains the key model. Auth type: **`oauth_2`** -- Integration level: `client_id`, `client_secret`, `redirect_url` (your Google OAuth app — shared) +- Integration level: `client_id`, `client_secret`, `redirect_url` (your Google OAuth app — shared across all Google plugins) - Account level: `access_token`, `refresh_token` (the user's grant — per-tenant) -Google Calendar and Google Drive share the same OAuth app (same client_id/client_secret), but they are **separate plugins** with separate token stores and separate OAuth flows. Set up only the plugin the user asked for. Tell the user upfront: "Google takes more steps than the others, but it's a one-time setup." +Google Calendar, Google Drive, and Gmail share the same OAuth app (same client_id/client_secret), but they are **separate plugins** with separate token stores and separate OAuth flows. Set up only the plugin the user asked for. Tell the user upfront: "Google takes more steps than the others, but it's a one-time setup." --- @@ -28,6 +28,7 @@ Google Calendar and Google Drive share the same OAuth app (same client_id/client Go to **APIs & Services → Library**: - For Google Calendar: search **Google Calendar API** → **Enable** - For Google Drive: search **Google Drive API** → **Enable** +- For Gmail: search **Gmail API** → **Enable** --- @@ -55,7 +56,7 @@ Go to **APIs & Services → Credentials → Create Credentials → OAuth client **Before running the setup script**, check that the plugin is registered in `server/corsair.ts`. Read the file and verify the plugin is imported and included in the `plugins` array. -For Google Calendar, it should look like: +For Google Calendar: ```typescript import { createCorsair, googlecalendar, slack } from 'corsair'; export const corsair = createCorsair({ @@ -73,15 +74,25 @@ export const corsair = createCorsair({ }); ``` +For Gmail: +```typescript +import { createCorsair, gmail, slack } from 'corsair'; +export const corsair = createCorsair({ + plugins: [slack(), gmail()], + ... +}); +``` + If the plugin is missing, add it now. The container will pick up the change automatically (via hot reload) — no restart needed. --- ## 6. Write and run the setup script -Ask the user to provide Client ID and Client Secret. Determine which plugin to set up based on what the user asked for: +Ask the user to provide Client ID and Client Secret. Set `PLUGIN` based on what the user asked for: - Google Calendar → `PLUGIN = 'googlecalendar'` - Google Drive → `PLUGIN = 'googledrive'` +- Gmail → `PLUGIN = 'gmail'` Then write `scripts/setup-google.ts`: @@ -100,8 +111,8 @@ const CLIENT_ID = '...'; const CLIENT_SECRET = '...'; // ───────────────────────────────────────────────────────────────────────────── -// Set to 'googlecalendar' or 'googledrive' depending on what the user asked for -const PLUGIN = 'googledrive'; +// Set to 'googlecalendar', 'googledrive', or 'gmail' depending on what the user asked for +const PLUGIN = 'gmail'; async function main() { console.log(`\nSetting up ${PLUGIN}...`); @@ -155,11 +166,8 @@ async function main() { await accountKeys.issue_new_dek(); console.log(` ✓ Account DEK ready`); - // 5. Print the correct OAuth URL for this plugin - const oauthUrl = PLUGIN === 'googledrive' - ? 'http://localhost:3000/oauth/googledrive' - : 'http://localhost:3000/oauth/googlecalendar'; - console.log(`\n✓ Credentials stored. Now complete OAuth at ${oauthUrl}`); + // 5. Print the OAuth URL for this plugin + console.log(`\n✓ Credentials stored. Now complete OAuth at http://localhost:3000/oauth/${PLUGIN}`); process.exit(0); } @@ -188,6 +196,7 @@ Tell the user to open the correct URL for the plugin they set up: |--------|-----------| | Google Calendar | http://localhost:3000/oauth/googlecalendar | | Google Drive | http://localhost:3000/oauth/googledrive | +| Gmail | http://localhost:3000/oauth/gmail | This will: 1. Redirect them to Google's consent screen @@ -208,3 +217,4 @@ No copying tokens manually — the server handles everything. **Re-authorizing:** If tokens expire or are revoked, just visit the correct URL for the plugin again: - Google Calendar: http://localhost:3000/oauth/googlecalendar - Google Drive: http://localhost:3000/oauth/googledrive +- Gmail: http://localhost:3000/oauth/gmail diff --git a/server/index.ts b/server/index.ts index f68edfe..f810dc9 100644 --- a/server/index.ts +++ b/server/index.ts @@ -212,6 +212,10 @@ async function main() { scope: 'https://www.googleapis.com/auth/drive', label: 'Google Drive', }, + gmail: { + scope: 'https://mail.google.com/', + label: 'Gmail', + }, } as const; async function startGoogleOAuth( @@ -243,6 +247,7 @@ async function main() { app.get('/oauth/googlecalendar', (req, res) => startGoogleOAuth('googlecalendar', res)); app.get('/oauth/googledrive', (req, res) => startGoogleOAuth('googledrive', res)); + app.get('/oauth/gmail', (req, res) => startGoogleOAuth('gmail', res)); // ── Spotify OAuth flow ──────────────────────────────────────────────────── From cfefc2e3121fe8bea3694bb2b642729913810bac Mon Sep 17 00:00:00 2001 From: Mukul yadav Date: Fri, 27 Feb 2026 13:04:33 +0530 Subject: [PATCH 3/5] feat: google skills --- .claude/skills/add-keys/gmail/SKILL.md | 42 +++++++++ .../skills/add-keys/google-calendar/SKILL.md | 42 +++++++++ .claude/skills/add-keys/google-drive/SKILL.md | 42 +++++++++ .claude/skills/add-keys/google/SKILL.md | 91 ++++--------------- server/index.ts | 11 ++- 5 files changed, 149 insertions(+), 79 deletions(-) create mode 100644 .claude/skills/add-keys/gmail/SKILL.md create mode 100644 .claude/skills/add-keys/google-calendar/SKILL.md create mode 100644 .claude/skills/add-keys/google-drive/SKILL.md diff --git a/.claude/skills/add-keys/gmail/SKILL.md b/.claude/skills/add-keys/gmail/SKILL.md new file mode 100644 index 0000000..847faef --- /dev/null +++ b/.claude/skills/add-keys/gmail/SKILL.md @@ -0,0 +1,42 @@ +--- +name: add-keys/gmail +description: Set up Gmail for Corsair. Use when the user wants to connect Gmail, read emails, send emails, or manage their inbox through the agent. +--- + +# Gmail Key Setup + +Read `/add-keys` and `/add-keys/google` first — they cover the shared Google OAuth steps (create Cloud project, OAuth consent screen, OAuth credentials, setup script, OAuth flow). + +**Plugin ID:** `gmail` +**OAuth URL:** `http://localhost:3000/oauth/gmail` + +--- + +## 1. Enable the Gmail API + +In your Google Cloud project, go to **APIs & Services → Library**: +- Search **Gmail API** → **Enable** + +--- + +## 2. Register the plugin in server/corsair.ts + +**Before running the setup script**, check that the plugin is registered. Read `server/corsair.ts` and verify `gmail` is imported and included in the `plugins` array: + +```typescript +import { createCorsair, gmail, slack } from 'corsair'; +export const corsair = createCorsair({ + plugins: [slack(), gmail()], + ... +}); +``` + +If missing, add it now. The container will pick up the change automatically — no restart needed. + +--- + +## 3. Run the common setup + +Follow steps 4–5 from `/add-keys/google` with: +- `PLUGIN = 'gmail'` +- OAuth URL: `http://localhost:3000/oauth/gmail` diff --git a/.claude/skills/add-keys/google-calendar/SKILL.md b/.claude/skills/add-keys/google-calendar/SKILL.md new file mode 100644 index 0000000..9ba0aa1 --- /dev/null +++ b/.claude/skills/add-keys/google-calendar/SKILL.md @@ -0,0 +1,42 @@ +--- +name: add-keys/google-calendar +description: Set up Google Calendar for Corsair. Use when the user wants to connect Google Calendar, read events, create calendar events, or manage their schedule through the agent. +--- + +# Google Calendar Key Setup + +Read `/add-keys` and `/add-keys/google` first — they cover the shared Google OAuth steps (create Cloud project, OAuth consent screen, OAuth credentials, setup script, OAuth flow). + +**Plugin ID:** `googlecalendar` +**OAuth URL:** `http://localhost:3000/oauth/googlecalendar` + +--- + +## 1. Enable the Google Calendar API + +In your Google Cloud project, go to **APIs & Services → Library**: +- Search **Google Calendar API** → **Enable** + +--- + +## 2. Register the plugin in server/corsair.ts + +**Before running the setup script**, check that the plugin is registered. Read `server/corsair.ts` and verify `googlecalendar` is imported and included in the `plugins` array: + +```typescript +import { createCorsair, googlecalendar, slack } from 'corsair'; +export const corsair = createCorsair({ + plugins: [slack(), googlecalendar()], + ... +}); +``` + +If missing, add it now. The container will pick up the change automatically — no restart needed. + +--- + +## 3. Run the common setup + +Follow steps 4–5 from `/add-keys/google` with: +- `PLUGIN = 'googlecalendar'` +- OAuth URL: `http://localhost:3000/oauth/googlecalendar` diff --git a/.claude/skills/add-keys/google-drive/SKILL.md b/.claude/skills/add-keys/google-drive/SKILL.md new file mode 100644 index 0000000..6633ee7 --- /dev/null +++ b/.claude/skills/add-keys/google-drive/SKILL.md @@ -0,0 +1,42 @@ +--- +name: add-keys/google-drive +description: Set up Google Drive for Corsair. Use when the user wants to connect Google Drive, read files, upload files, or manage their drive through the agent. +--- + +# Google Drive Key Setup + +Read `/add-keys` and `/add-keys/google` first — they cover the shared Google OAuth steps (create Cloud project, OAuth consent screen, OAuth credentials, setup script, OAuth flow). + +**Plugin ID:** `googledrive` +**OAuth URL:** `http://localhost:3000/oauth/googledrive` + +--- + +## 1. Enable the Google Drive API + +In your Google Cloud project, go to **APIs & Services → Library**: +- Search **Google Drive API** → **Enable** + +--- + +## 2. Register the plugin in server/corsair.ts + +**Before running the setup script**, check that the plugin is registered. Read `server/corsair.ts` and verify `googledrive` is imported and included in the `plugins` array: + +```typescript +import { createCorsair, googledrive, slack } from 'corsair'; +export const corsair = createCorsair({ + plugins: [slack(), googledrive()], + ... +}); +``` + +If missing, add it now. The container will pick up the change automatically — no restart needed. + +--- + +## 3. Run the common setup + +Follow steps 4–5 from `/add-keys/google` with: +- `PLUGIN = 'googledrive'` +- OAuth URL: `http://localhost:3000/oauth/googledrive` diff --git a/.claude/skills/add-keys/google/SKILL.md b/.claude/skills/add-keys/google/SKILL.md index 368f10f..e890903 100644 --- a/.claude/skills/add-keys/google/SKILL.md +++ b/.claude/skills/add-keys/google/SKILL.md @@ -1,9 +1,9 @@ --- name: add-keys/google -description: Set up Google OAuth credentials for Corsair. Use when the user wants to connect Google Calendar, Google Drive, or Gmail. All three plugins share the same OAuth app and credentials. +description: Common Google OAuth setup for Corsair. Contains shared steps used by all Google plugins (Gmail, Google Calendar, Google Drive). Read this when setting up any Google integration — then read the plugin-specific skill for the remaining steps. --- -# Google Key Setup (Calendar + Drive + Gmail) +# Google OAuth — Common Setup Read `/add-keys` first if you haven't — it explains the key model. @@ -11,7 +11,7 @@ Auth type: **`oauth_2`** - Integration level: `client_id`, `client_secret`, `redirect_url` (your Google OAuth app — shared across all Google plugins) - Account level: `access_token`, `refresh_token` (the user's grant — per-tenant) -Google Calendar, Google Drive, and Gmail share the same OAuth app (same client_id/client_secret), but they are **separate plugins** with separate token stores and separate OAuth flows. Set up only the plugin the user asked for. Tell the user upfront: "Google takes more steps than the others, but it's a one-time setup." +Gmail, Google Calendar, and Google Drive share the same OAuth app (same client_id/client_secret), but they are **separate plugins** with separate token stores and separate OAuth flows. Set up only the plugin the user asked for. Tell the user upfront: "Google takes more steps than the others, but it's a one-time setup." --- @@ -23,16 +23,7 @@ Google Calendar, Google Drive, and Gmail share the same OAuth app (same client_i --- -## 2. Enable APIs - -Go to **APIs & Services → Library**: -- For Google Calendar: search **Google Calendar API** → **Enable** -- For Google Drive: search **Google Drive API** → **Enable** -- For Gmail: search **Gmail API** → **Enable** - ---- - -## 3. Configure OAuth consent screen +## 2. Configure OAuth consent screen Go to **APIs & Services → OAuth consent screen**: 1. Choose **External** → **Create** @@ -43,7 +34,7 @@ Go to **APIs & Services → OAuth consent screen**: --- -## 4. Create OAuth credentials +## 3. Create OAuth credentials Go to **APIs & Services → Credentials → Create Credentials → OAuth client ID**: 1. Application type: **Web application** → Name: "Corsair" @@ -52,49 +43,11 @@ Go to **APIs & Services → Credentials → Create Credentials → OAuth client --- -## 5. Register the plugin in server/corsair.ts - -**Before running the setup script**, check that the plugin is registered in `server/corsair.ts`. Read the file and verify the plugin is imported and included in the `plugins` array. - -For Google Calendar: -```typescript -import { createCorsair, googlecalendar, slack } from 'corsair'; -export const corsair = createCorsair({ - plugins: [slack(), googlecalendar()], - ... -}); -``` - -For Google Drive: -```typescript -import { createCorsair, googledrive, slack } from 'corsair'; -export const corsair = createCorsair({ - plugins: [slack(), googledrive()], - ... -}); -``` - -For Gmail: -```typescript -import { createCorsair, gmail, slack } from 'corsair'; -export const corsair = createCorsair({ - plugins: [slack(), gmail()], - ... -}); -``` - -If the plugin is missing, add it now. The container will pick up the change automatically (via hot reload) — no restart needed. - ---- - -## 6. Write and run the setup script +## 4. Write and run the setup script -Ask the user to provide Client ID and Client Secret. Set `PLUGIN` based on what the user asked for: -- Google Calendar → `PLUGIN = 'googlecalendar'` -- Google Drive → `PLUGIN = 'googledrive'` -- Gmail → `PLUGIN = 'gmail'` +Ask the user for their Client ID and Client Secret. Fill in `PLUGIN` from the plugin-specific skill (`gmail`, `googlecalendar`, or `googledrive`). -Then write `scripts/setup-google.ts`: +Write `scripts/setup-google.ts`: ```typescript import 'dotenv/config'; @@ -111,8 +64,8 @@ const CLIENT_ID = '...'; const CLIENT_SECRET = '...'; // ───────────────────────────────────────────────────────────────────────────── -// Set to 'googlecalendar', 'googledrive', or 'gmail' depending on what the user asked for -const PLUGIN = 'gmail'; +// Set to 'googlecalendar', 'googledrive', or 'gmail' +const PLUGIN = '...'; async function main() { console.log(`\nSetting up ${PLUGIN}...`); @@ -166,7 +119,6 @@ async function main() { await accountKeys.issue_new_dek(); console.log(` ✓ Account DEK ready`); - // 5. Print the OAuth URL for this plugin console.log(`\n✓ Credentials stored. Now complete OAuth at http://localhost:3000/oauth/${PLUGIN}`); process.exit(0); } @@ -188,21 +140,13 @@ rm scripts/setup-google.ts --- -## 7. Complete the OAuth flow - -Tell the user to open the correct URL for the plugin they set up: - -| Plugin | OAuth URL | -|--------|-----------| -| Google Calendar | http://localhost:3000/oauth/googlecalendar | -| Google Drive | http://localhost:3000/oauth/googledrive | -| Gmail | http://localhost:3000/oauth/gmail | +## 5. Complete the OAuth flow -This will: +Tell the user to open the OAuth URL for the plugin they set up (listed in the plugin-specific skill). The flow will: 1. Redirect them to Google's consent screen 2. After they click Allow, redirect back to `/oauth/callback` -3. Automatically exchange the code for tokens and store both `access_token` and `refresh_token` for the correct plugin -4. Show a success page naming the correct plugin +3. Automatically exchange the code for tokens and store both `access_token` and `refresh_token` +4. Show a success page No copying tokens manually — the server handles everything. @@ -210,11 +154,8 @@ No copying tokens manually — the server handles everything. ## Notes -**Token refresh:** The plugin calls `getValidAccessToken()` internally on every request, using the stored `refresh_token` + `client_id` + `client_secret` to get a fresh access token. However, it also requires an `access_token` to be stored — the OAuth flow at step 6 stores both. +**Token refresh:** The plugin calls `getValidAccessToken()` internally on every request, using the stored `refresh_token` + `client_id` + `client_secret` to get a fresh access token. **Token expiry (test mode):** Google OAuth refresh tokens for apps in test mode expire after 7 days of inactivity. To avoid this, publish the app: **OAuth consent screen → Publish App**. The unverified app warning during login is fine for personal use. -**Re-authorizing:** If tokens expire or are revoked, just visit the correct URL for the plugin again: -- Google Calendar: http://localhost:3000/oauth/googlecalendar -- Google Drive: http://localhost:3000/oauth/googledrive -- Gmail: http://localhost:3000/oauth/gmail +**Re-authorizing:** If tokens expire or are revoked, just visit the plugin's OAuth URL again (listed in the plugin-specific skill). diff --git a/server/index.ts b/server/index.ts index f810dc9..951cbcd 100644 --- a/server/index.ts +++ b/server/index.ts @@ -205,15 +205,18 @@ async function main() { const GOOGLE_PLUGIN_CONFIG = { googlecalendar: { - scope: 'https://www.googleapis.com/auth/calendar', + scope: ['https://www.googleapis.com/auth/calendar'], label: 'Google Calendar', }, googledrive: { - scope: 'https://www.googleapis.com/auth/drive', + scope: ['https://www.googleapis.com/auth/drive'], label: 'Google Drive', }, gmail: { - scope: 'https://mail.google.com/', + scope:[ 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.labels', + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/gmail.compose'], label: 'Gmail', }, } as const; @@ -233,7 +236,7 @@ async function main() { url.searchParams.set('client_id', creds.client_id); url.searchParams.set('redirect_uri', creds.redirect_url); url.searchParams.set('response_type', 'code'); - url.searchParams.set('scope', GOOGLE_PLUGIN_CONFIG[plugin].scope); + url.searchParams.set('scope', GOOGLE_PLUGIN_CONFIG[plugin].scope.join(' ')); url.searchParams.set('access_type', 'offline'); url.searchParams.set('prompt', 'consent'); url.searchParams.set('state', plugin); From 2368a76760dd063aa29adcf9075d03acb69aa71c Mon Sep 17 00:00:00 2001 From: Mukul <130670365+mukul7661@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:23:42 +0530 Subject: [PATCH 4/5] Potential fix for code scanning alert no. 4: Missing rate limiting Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- server/index.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/server/index.ts b/server/index.ts index 951cbcd..20aa213 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3,6 +3,7 @@ import { createExpressMiddleware } from '@trpc/server/adapters/express'; import { processWebhook } from 'corsair'; import { asc, eq } from 'drizzle-orm'; import express from 'express'; +import rateLimit from 'express-rate-limit'; import type { SimpleMessage } from './agent'; import { runAgent, WORKFLOW_FAILURE_PROMPT } from './agent'; import { corsair } from './corsair'; @@ -221,6 +222,13 @@ async function main() { }, } as const; + const oauthLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 20, // limit each IP to 20 OAuth requests per window + standardHeaders: true, + legacyHeaders: false, + }); + async function startGoogleOAuth( plugin: keyof typeof GOOGLE_PLUGIN_CONFIG, res: import('express').Response, @@ -248,13 +256,19 @@ async function main() { } } - app.get('/oauth/googlecalendar', (req, res) => startGoogleOAuth('googlecalendar', res)); - app.get('/oauth/googledrive', (req, res) => startGoogleOAuth('googledrive', res)); - app.get('/oauth/gmail', (req, res) => startGoogleOAuth('gmail', res)); + app.get('/oauth/googlecalendar', oauthLimiter, (req, res) => + startGoogleOAuth('googlecalendar', res), + ); + app.get('/oauth/googledrive', oauthLimiter, (req, res) => + startGoogleOAuth('googledrive', res), + ); + app.get('/oauth/gmail', oauthLimiter, (req, res) => + startGoogleOAuth('gmail', res), + ); // ── Spotify OAuth flow ──────────────────────────────────────────────────── - app.get('/oauth/spotify', async (req, res) => { + app.get('/oauth/spotify', oauthLimiter, async (req, res) => { try { const creds = await corsair.spotify.keys.get_integration_credentials(); if (!creds.client_id || !creds.redirect_url) { From f332dbe98d5453d150b9136bda523bb368de06c7 Mon Sep 17 00:00:00 2001 From: Mukul yadav Date: Mon, 2 Mar 2026 22:02:24 +0530 Subject: [PATCH 5/5] feat: calendar webhook flow --- .../skills/add-keys/google-calendar/SKILL.md | 230 ++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 13 + server/corsair.ts | 4 +- 4 files changed, 246 insertions(+), 2 deletions(-) diff --git a/.claude/skills/add-keys/google-calendar/SKILL.md b/.claude/skills/add-keys/google-calendar/SKILL.md index 9ba0aa1..0163404 100644 --- a/.claude/skills/add-keys/google-calendar/SKILL.md +++ b/.claude/skills/add-keys/google-calendar/SKILL.md @@ -40,3 +40,233 @@ If missing, add it now. The container will pick up the change automatically — Follow steps 4–5 from `/add-keys/google` with: - `PLUGIN = 'googlecalendar'` - OAuth URL: `http://localhost:3000/oauth/googlecalendar` + +--- + +## 4. Webhook Setup (Optional) + +Ask the user: "Would you like to set up webhooks so Corsair can trigger automations when your calendar events change? This requires a public HTTPS URL." + +If no → done. Google Calendar is connected for reading/writing events. + +If yes → continue below. + +--- + +## 5. Get a public webhook URL + +Ask: "Do you already have a public HTTPS URL for this Corsair server (e.g. from Railway, Render, or a VPS)?" + +- If yes → the webhook endpoint is `{their-url}/api/webhook`. Skip to Step 7. +- If no → proceed to Step 6 (ngrok). + +--- + +## 6. Set up ngrok + +Install ngrok if needed: +- Mac: `brew install ngrok` +- Or download from https://ngrok.com/download + +Authenticate: +1. Sign up at https://ngrok.com +2. Go to https://dashboard.ngrok.com/get-started/your-authtoken +3. Run: `ngrok config add-authtoken YOUR_TOKEN` + +Start the tunnel (keep this terminal open): +```bash +ngrok http 3001 +``` + +Copy the HTTPS forwarding URL (e.g. `https://abc123.ngrok-free.app`). The webhook endpoint is `{ngrok-url}/api/webhook`. + +--- + +## 7. Register the watch channel + +Write `scripts/setup-gcal-webhook.ts`, filling in `WEBHOOK_URL` with the full endpoint URL from the previous step: + +```typescript +import 'dotenv/config'; +import * as crypto from 'node:crypto'; +import { corsair } from '../server/corsair'; + +const WEBHOOK_URL = 'REPLACE_WITH_WEBHOOK_URL'; // e.g. https://abc123.ngrok-free.app/api/webhook +const CALENDAR_ID = 'primary'; // or a specific calendar ID + +const main = async () => { + const [clientId, clientSecret, refreshToken] = await Promise.all([ + corsair.keys.googlecalendar.get_client_id(), + corsair.keys.googlecalendar.get_client_secret(), + corsair.googlecalendar.keys.get_refresh_token(), + ]); + + if (!clientId || !clientSecret || !refreshToken) { + console.error('Missing credentials — complete credential setup first.'); + process.exit(1); + } + + // Get a fresh access token + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: 'refresh_token', + }), + }); + + if (!tokenRes.ok) { + console.error('Token refresh failed:', await tokenRes.text()); + process.exit(1); + } + + const { access_token } = (await tokenRes.json()) as { access_token: string }; + + // Create the watch channel + const channelId = crypto.randomUUID(); + + const watchRes = await fetch( + 'https://www.googleapis.com/calendar/v3/calendars/' + + encodeURIComponent(CALENDAR_ID) + '/events/watch', + { + method: 'POST', + headers: { + Authorization: 'Bearer ' + access_token, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ id: channelId, type: 'web_hook', address: WEBHOOK_URL }), + }, + ); + + if (!watchRes.ok) { + console.error('Calendar watch failed:', await watchRes.text()); + process.exit(1); + } + + const data = (await watchRes.json()) as { + id: string; + resourceId: string; + expiration: string; + }; + + const expiration = new Date(Number(data.expiration)).toISOString(); + console.log('✓ Watch channel created'); + console.log(' Channel ID :', channelId); + console.log(' Resource ID:', data.resourceId); + console.log(' Expires :', expiration); + console.log('\nIMPORTANT: Re-run this script before the channel expires to renew it.'); + process.exit(0); +}; + +main().catch((e) => { console.error(e); process.exit(1); }); +``` + +Run it: +```bash +docker compose exec agent pnpm tsx scripts/setup-gcal-webhook.ts +``` + +Then delete the script: +```bash +rm scripts/setup-gcal-webhook.ts +``` + +Report the Channel ID, Resource ID, and expiration to the user. Webhook events will now arrive at `/api/webhook` and trigger any workflows configured with `plugin: 'googlecalendar'`. + +--- + +## 8. Auto-renewal cron workflow + +Google Calendar watch channels expire after ~7 days. Set up a cron workflow that renews the channel every 6 days automatically. The workflow reads credentials from the DB (via `corsair`) and fetches the current ngrok URL from the docker-compose ngrok service at `http://ngrok:4040`. + +Write `scripts/setup-gcal-renewal-workflow.ts`: + +```typescript +import 'dotenv/config'; +import { registerCronWorkflow } from '../server/workflow-scheduler'; +import { storeWorkflow } from '../server/executor'; + +const WORKFLOW_NAME = 'renewGoogleCalendarWatch'; +const CALENDAR_ID = 'primary'; + +const code = ` +async function ${WORKFLOW_NAME}() { + // Fetch the current ngrok public URL from the docker-compose ngrok service + const tunnelsRes = await fetch('http://ngrok:4040/api/tunnels'); + if (!tunnelsRes.ok) throw new Error('Could not reach ngrok API'); + const { tunnels } = await tunnelsRes.json() as { tunnels: Array<{ public_url: string; proto: string }> }; + const tunnel = tunnels.find(t => t.proto === 'https'); + if (!tunnel) throw new Error('No HTTPS ngrok tunnel found'); + const webhookUrl = tunnel.public_url + '/api/webhook'; + + // Get credentials + const [clientId, clientSecret, refreshToken] = await Promise.all([ + corsair.keys.googlecalendar.get_client_id(), + corsair.keys.googlecalendar.get_client_secret(), + corsair.googlecalendar.keys.get_refresh_token(), + ]); + if (!clientId || !clientSecret || !refreshToken) throw new Error('Missing Google credentials'); + + // Refresh access token + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, refresh_token: refreshToken, grant_type: 'refresh_token' }), + }); + if (!tokenRes.ok) throw new Error('Token refresh failed: ' + await tokenRes.text()); + const { access_token } = await tokenRes.json() as { access_token: string }; + + // Register new watch channel + const channelId = crypto.randomUUID(); + const watchRes = await fetch( + 'https://www.googleapis.com/calendar/v3/calendars/' + encodeURIComponent('${CALENDAR_ID}') + '/events/watch', + { + method: 'POST', + headers: { Authorization: 'Bearer ' + access_token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: channelId, type: 'web_hook', address: webhookUrl }), + }, + ); + if (!watchRes.ok) throw new Error('Calendar watch failed: ' + await watchRes.text()); + const data = await watchRes.json() as { id: string; resourceId: string; expiration: string }; + console.log('✓ Watch channel renewed'); + console.log(' Channel ID :', channelId); + console.log(' Resource ID:', data.resourceId); + console.log(' Expires :', new Date(Number(data.expiration)).toISOString()); + console.log(' Webhook URL:', webhookUrl); +} +`; + +async function main() { + // Every 6 days at 00:00 (safe margin before 7-day expiry) + const cronSchedule = '0 0 */6 * *'; + + const workflow = await storeWorkflow({ + type: 'workflow', + workflowId: WORKFLOW_NAME, + code, + description: 'Renews the Google Calendar watch channel every 6 days', + cronSchedule, + }); + + registerCronWorkflow(workflow!.id, WORKFLOW_NAME, code, cronSchedule); + console.log(`✓ Cron workflow "${WORKFLOW_NAME}" registered (${cronSchedule})`); + process.exit(0); +} + +main().catch((e) => { console.error(e); process.exit(1); }); +``` + +Run it: +```bash +docker compose exec agent pnpm tsx scripts/setup-gcal-renewal-workflow.ts +``` + +Then delete the script: +```bash +rm scripts/setup-gcal-renewal-workflow.ts +``` + +The workflow is now stored in the DB and will be loaded automatically on every server restart. It renews the watch channel 6 days after the last renewal, keeping webhooks alive indefinitely. diff --git a/package.json b/package.json index b2d128f..9f8c17f 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dotenv": "^16.4.0", "drizzle-orm": "^0.44.5", "express": "^4.21.0", + "express-rate-limit": "^7.0.0", "grammy": "^1.39.3", "mem0ai": "^2.2.3", "node-cron": "^4.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c1cad8..31e33ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: express: specifier: ^4.21.0 version: 4.22.1 + express-rate-limit: + specifier: ^7.0.0 + version: 7.5.1(express@4.22.1) grammy: specifier: ^1.39.3 version: 1.40.0(encoding@0.1.13) @@ -1828,6 +1831,12 @@ packages: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + express-rate-limit@7.5.1: + resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@4.22.1: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} @@ -4687,6 +4696,10 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 + express-rate-limit@7.5.1(express@4.22.1): + dependencies: + express: 4.22.1 + express@4.22.1: dependencies: accepts: 1.3.8 diff --git a/server/corsair.ts b/server/corsair.ts index 16325c7..43f893f 100644 --- a/server/corsair.ts +++ b/server/corsair.ts @@ -1,8 +1,8 @@ -import { createCorsair, slack, spotify } from 'corsair'; +import { createCorsair, googlecalendar, slack, spotify } from 'corsair'; import { pool } from './db'; export const corsair = createCorsair({ - plugins: [slack(), spotify()], + plugins: [slack(), spotify(), googlecalendar()], database: pool, kek: process.env.CORSAIR_KEK!, multiTenancy: false,