diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/docs/agent-tutorial.html b/docs/agent-tutorial.html new file mode 100644 index 0000000..846e0ed --- /dev/null +++ b/docs/agent-tutorial.html @@ -0,0 +1,231 @@ + + + + + +Agent Tutorial — SquareMCP Docs + + + + + + +
+
+

Build a LinkedIn posting agent

+

Real code: a Claude agent that researches a topic, drafts a post, and publishes it to LinkedIn — fully automated.

+
+ +
+ What you'll build + A Node.js script that (1) takes a topic from the command line, (2) uses Claude to research and draft a LinkedIn post, (3) calls the SquareMCP linkedin_create_post tool to publish — all in one agentic loop. +
+ +

Prerequisites

+ + +

Step 1 — Install dependencies

+
npm init -y
+npm install @anthropic-ai/sdk @modelcontextprotocol/sdk
+ +

Step 2 — Create the agent

+ +

Create linkedin-agent.mjs:

+ +
import Anthropic from '@anthropic-ai/sdk';
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
+
+const SQUAREMCP_URL  = 'https://hermes.squaremcp.com/mcp';
+const SQUAREMCP_TOKEN = process.env.SQUAREMCP_TOKEN;  // your Bearer token
+const topic = process.argv[2] ?? 'AI trends in 2026';
+
+// ── 1. Connect to SquareMCP ──────────────────────────────────────
+const transport = new StreamableHTTPClientTransport(new URL(SQUAREMCP_URL), {
+  requestInit: { headers: { Authorization: `Bearer ${SQUAREMCP_TOKEN}` } },
+});
+
+const mcpClient = new Client({ name: 'linkedin-agent', version: '1.0.0' });
+await mcpClient.connect(transport);
+
+// Fetch available tools from SquareMCP
+const { tools: mcpTools } = await mcpClient.listTools();
+
+// Convert MCP tool descriptors to Anthropic tool format
+const anthropicTools = mcpTools.map(t => ({
+  name: t.name,
+  description: t.description,
+  input_schema: t.inputSchema,
+}));
+
+// ── 2. Run the agentic loop ──────────────────────────────────────
+const anthropic = new Anthropic();
+const messages = [
+  {
+    role: 'user',
+    content: `You are a LinkedIn content strategist. Your job:
+1. Think about the topic: "${topic}"
+2. Draft a compelling LinkedIn post (150-250 words, professional tone, 3-5 hashtags)
+3. Call linkedin_create_post to publish it
+4. Report back what was posted.
+
+Write the post now and publish it.`,
+  },
+];
+
+console.log(`\n🤖 Agent starting — topic: "${topic}"\n`);
+
+while (true) {
+  const response = await anthropic.messages.create({
+    model: 'claude-opus-4-7',
+    max_tokens: 1024,
+    tools: anthropicTools,
+    messages,
+  });
+
+  // Append assistant turn
+  messages.push({ role: 'assistant', content: response.content });
+
+  if (response.stop_reason === 'end_turn') {
+    const text = response.content
+      .filter(b => b.type === 'text')
+      .map(b => b.text)
+      .join('\n');
+    console.log('\n✅ Agent finished:\n', text);
+    break;
+  }
+
+  if (response.stop_reason !== 'tool_use') break;
+
+  // Execute each tool call against SquareMCP
+  const toolResults = [];
+  for (const block of response.content) {
+    if (block.type !== 'tool_use') continue;
+
+    console.log(`🔧 Calling ${block.name}...`);
+    let result;
+    try {
+      result = await mcpClient.callTool({ name: block.name, arguments: block.input });
+      console.log(`   ✓ ${JSON.stringify(result.content[0]).slice(0, 120)}...`);
+    } catch (err) {
+      result = { content: [{ type: 'text', text: `Error: ${err.message}` }] };
+      console.error(`   ✗ ${err.message}`);
+    }
+
+    toolResults.push({
+      type: 'tool_result',
+      tool_use_id: block.id,
+      content: result.content,
+    });
+  }
+
+  messages.push({ role: 'user', content: toolResults });
+}
+
+await mcpClient.close();
+ +

Step 3 — Run it

+ +
export ANTHROPIC_API_KEY=sk-ant-...
+export SQUAREMCP_TOKEN=your-bearer-token-here
+
+node linkedin-agent.mjs "The future of AI agents in enterprise software"
+ +

Expected output:

+ +
🤖 Agent starting — topic: "The future of AI agents in enterprise software"
+
+🔧 Calling linkedin_create_post...
+   ✓ {"id":"urn:li:share:7194...","success":true}...
+
+✅ Agent finished:
+ I've published the following LinkedIn post:
+
+"Enterprise software is undergoing a quiet revolution..."
+[full post text]
+
+The post has been published successfully to your LinkedIn feed.
+ +

Step 4 — Extend it

+ +

Now that you have the agentic loop working, you can extend it:

+ +

Post to multiple platforms at once

+
// Replace the user message with:
+content: `Draft a post about "${topic}" and publish it on both
+LinkedIn (professional tone) and Twitter (punchy, max 280 chars, 2 hashtags).
+Use linkedin_create_post and twitter_create_tweet.`
+ +

Schedule with a cron job

+
# Post every weekday at 9am
+0 9 * * 1-5 SQUAREMCP_TOKEN=... ANTHROPIC_API_KEY=... \
+  node /path/to/linkedin-agent.mjs "$(cat /path/to/topics.txt | shuf -n1)"
+ +

React to inbound WhatsApp messages

+

Configure a webhook in the SquareMCP dashboard. When a WhatsApp message arrives, SquareMCP POSTs to your server. Run the same agent loop but start with the inbound message as context:

+ +
import express from 'express';
+import crypto from 'crypto';
+
+const app = express();
+app.use(express.json());
+
+app.post('/webhook', async (req, res) => {
+  // Verify signature
+  const sig = req.headers['x-squaremcp-signature'];
+  const expected = `sha256=${crypto
+    .createHmac('sha256', process.env.WEBHOOK_SECRET)
+    .update(JSON.stringify(req.body))
+    .digest('hex')}`;
+  if (sig !== expected) { res.status(401).end(); return; }
+
+  res.status(200).end(); // acknowledge immediately
+
+  const { platform, data } = req.body;
+  console.log(`Inbound from ${platform}: ${data.text}`);
+
+  // Run agent in background...
+  runAgent(data).catch(console.error);
+});
+
+app.listen(3000);
+ +

Going further

+ +
+ +

← Getting started

+

Configure Claude Desktop, Codex CLI, or opencode.

+
+ +

Platform guides

+

Connect TikTok, WhatsApp, Instagram, and more.

+
+ +

API reference ↗

+

Full tool schemas for every platform.

+
+ +

Anthropic tool use ↗

+

Deep dive into Claude's tool-use API.

+
+
+
+ + + diff --git a/docs/getting-started.html b/docs/getting-started.html new file mode 100644 index 0000000..54d14c9 --- /dev/null +++ b/docs/getting-started.html @@ -0,0 +1,185 @@ + + + + + +Getting Started — SquareMCP Docs + + + + + + +
+
+

Getting started

+

Connect your AI assistant to SquareMCP in five minutes. Choose your client below.

+
+ +

Step 1 — Create your account

+
    +
  1. +
    + Sign up at squaremcp.com + Open the SquareMCP dashboard, create an account, and verify your email. +
    +
  2. +
  3. +
    + Get your access token + Click Connect MCP Client in the dashboard. This opens a short OAuth flow that issues a Bearer token bound to your account. + Copy the token shown on the confirmation page — it won't be displayed again. +
    +
  4. +
  5. +
    + Connect at least one platform + Go to Platforms and connect LinkedIn, TikTok, WhatsApp, or any other service. See Platform guides for step-by-step instructions per platform. +
    +
  6. +
+ +

Step 2 — Configure your MCP client

+ +
+ + + +
+ +
+
// ~/Library/Application Support/Claude/claude_desktop_config.json  (macOS)
+// %APPDATA%\Claude\claude_desktop_config.json                       (Windows)
+{
+  "mcpServers": {
+    "squaremcp": {
+      "type": "http",
+      "url": "https://hermes.squaremcp.com/mcp",
+      "headers": {
+        "Authorization": "Bearer YOUR_TOKEN_HERE"
+      }
+    }
+  }
+}
+

Restart Claude Desktop after saving. You should see SquareMCP tools in the tool picker (hammer icon).

+
+ +
+
# ~/.codex/config.json
+{
+  "mcpServers": {
+    "squaremcp": {
+      "type": "http",
+      "url": "https://hermes.squaremcp.com/mcp",
+      "headers": {
+        "Authorization": "Bearer YOUR_TOKEN_HERE"
+      }
+    }
+  }
+}
+

Or pass inline per command:

+
codex --mcp-server squaremcp=https://hermes.squaremcp.com/mcp \
+      --mcp-header squaremcp:Authorization="Bearer YOUR_TOKEN_HERE" \
+      "Post a LinkedIn update about today's product launch"
+
+ PKCE flow (optional) + Codex CLI supports the full OAuth PKCE flow. Run codex auth squaremcp and follow the browser prompt — no token copy-paste required. +
+
+ +
+
# ~/.config/opencode/config.json
+{
+  "mcp": {
+    "servers": {
+      "squaremcp": {
+        "type": "http",
+        "url": "https://hermes.squaremcp.com/mcp",
+        "headers": {
+          "Authorization": "Bearer YOUR_TOKEN_HERE"
+        }
+      }
+    }
+  }
+}
+

Save the file and restart opencode. The SquareMCP tools will appear in the tool list automatically.

+
+ +

Step 3 — Verify the connection

+

Ask your AI assistant:

+
What social platforms do I have connected?
+

It should call get_profile or linkedin_get_profile and return your account details. If you see a "Platform not connected" error, revisit the Platform guides.

+ +

Available tools

+

SquareMCP exposes tools across every connected platform. A few highlights:

+ +
+
+

linkedin_create_post

+

Publish text, image, or video to your LinkedIn feed.

+
+
+

tiktok_create_video

+

Upload a video file and publish it to TikTok.

+
+
+

whatsapp_send_message

+

Send a WhatsApp message to any number via Business API.

+
+
+

twitter_create_tweet

+

Post a tweet with optional media attachment.

+
+
+

instagram_create_reel

+

Publish a reel to your Instagram Business account.

+
+
+

send_email

+

Send email from any connected IMAP/SMTP account.

+
+
+ +

See the full list in the API reference.

+ +

Troubleshooting

+ +

Tools not appearing in Claude

+

Restart Claude Desktop after editing claude_desktop_config.json. If tools still don't appear, open the Claude Desktop developer console and look for MCP connection errors.

+ +

"Platform not connected" errors

+

The tool was called but the platform isn't linked to your account. Open the dashboard and connect the platform under Platforms.

+ +

"Token expired" badge in dashboard

+

OAuth tokens for LinkedIn, TikTok, and Instagram expire. SquareMCP attempts an automatic refresh — if that fails, reconnect the platform. WhatsApp, Telegram, and Discord use long-lived bot tokens that don't expire.

+ +

Rate limit errors

+

Each SquareMCP plan has a monthly tool-call limit. Check Usage in the dashboard. Upgrade your plan if you're consistently hitting the limit.

+ +
+ Keep your Bearer token secret + Your Bearer token has full access to every connected platform. Treat it like a password. Rotate it from the dashboard if you suspect it's been exposed. +
+
+ + + + diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..e6c29c8 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,94 @@ + + + + + +SquareMCP Docs — AI Social Media Gateway + + + + + + + +
+
+

Build AI agents that
talk to the world

+

SquareMCP connects Claude, Codex CLI, and opencode to LinkedIn, TikTok, WhatsApp, Instagram, Twitter, and more — through a single MCP server.

+
+ +
+ +

🚀 Getting started

+

Add SquareMCP to Claude Desktop, Codex CLI, or opencode in five minutes.

+
+ +

🔌 Platform guides

+

Connect LinkedIn, TikTok, and WhatsApp. Step-by-step setup for each platform.

+
+ +

🤖 Agent tutorial

+

Real code: a Claude agent that researches news and posts to LinkedIn automatically.

+
+ +

📖 API reference ↗

+

Full OpenAPI spec for every social tool. Mail API available separately.

+
+
+ +

Why MCP?

+

The Model Context Protocol lets AI assistants call real tools without custom integrations. Instead of writing a bespoke connector for every model and platform, you configure SquareMCP once and every MCP-compatible client gains the same 50+ tools.

+

SquareMCP is multi-tenant by design: each customer's credentials are encrypted at rest, isolated per account, and never shared across sessions. Bearer tokens issued through OAuth are bound to your customer record so every tool call is attributable and rate-limited.

+ +

Supported clients

+
+
+

Claude Desktop

+

Add a mcpServers entry to claude_desktop_config.json. No extra software needed.

+
+
+

Codex CLI

+

Pass --mcp-server or configure in ~/.codex/config.json. Full PKCE OAuth flow supported.

+
+
+

opencode

+

Add SquareMCP to your opencode MCP providers list. HTTP Bearer transport.

+
+
+

Custom agents

+

Any MCP client that supports Streamable HTTP transport works. See the agent tutorial for a from-scratch example.

+
+
+ +

Supported platforms

+
+

in LinkedIn

Post text, images, and video. Search connections, send messages.

+

🎵 TikTok

Upload and publish videos. View creator analytics.

+

💬 WhatsApp

Send messages and templates. Receive inbound via webhook.

+

📷 Instagram

Publish reels and image posts via Business API.

+

𝕏 Twitter / X

Tweet with media. Search and read timeline.

+

f Facebook

Post to pages, share photos and video.

+

✈️ Telegram

Send messages and photos via bot token.

+

🎮 Discord

Send messages to channels via bot.

+
+
+ + + + diff --git a/docs/platforms.html b/docs/platforms.html new file mode 100644 index 0000000..2624cd9 --- /dev/null +++ b/docs/platforms.html @@ -0,0 +1,288 @@ + + + + + +Platform Guides — SquareMCP Docs + + + + + + +
+
+

Platform guides

+

Step-by-step instructions for connecting each platform. Click a platform to jump to its guide.

+
+ +
+

in LinkedIn

OAuth access token, post text and video.

+

🎵 TikTok

Login Kit OAuth flow, upload video.

+

💬 WhatsApp

Business API, templates, inbound webhooks.

+

📷 Instagram

Business account, Graph API token.

+

𝕏 Twitter / X

API v2 credentials, tweet with media.

+

✈️ Telegram

Bot token from BotFather.

+
+ + +

in LinkedIn

+ +

What you can do

+ + +

Connecting LinkedIn

+
    +
  1. +
    + Create a LinkedIn app + Go to developer.linkedin.com/apps and create a new app. Add your company page and request the w_member_social and r_liteprofile products. +
    +
  2. +
  3. +
    + Generate an access token + Use the LinkedIn OAuth 2.0 token generator in your app dashboard, or use the 3-legged OAuth flow. Copy the access token (valid 60 days; refresh tokens extend to ~12 months). +
    +
  4. +
  5. +
    + Paste into SquareMCP dashboard + Open the SquareMCP dashboard → LinkedIn → Connect → paste your access token → Save. +
    +
  6. +
+ +

Available tools

+

Example prompts

+
Post a LinkedIn update: "Excited to announce our new product launch!"
+
+Post a LinkedIn video from /tmp/demo.mp4 with caption "Watch our product demo"
+
+Search my LinkedIn connections for people at Anthropic
+
+Send a LinkedIn message to John Smith saying "Great meeting you at the conference"
+ +
+ Token refresh + LinkedIn access tokens expire after 60 days. SquareMCP will automatically attempt a refresh using the refresh token. If refresh fails (e.g., the user revokes access), reconnect from the dashboard. +
+ + +

🎵 TikTok

+ +

What you can do

+ + +

Connecting TikTok

+
    +
  1. +
    + OAuth via SquareMCP dashboard + Open the SquareMCP dashboard → TikTok → Connect. You'll be redirected to TikTok to authorise the app. SquareMCP uses TikTok Login Kit with video.publish and user.info.basic scopes. +
    +
  2. +
  3. +
    + Allow the requested permissions + TikTok shows the requested scopes. Click Authorize. You'll be redirected back to the dashboard with a "Connected" badge. +
    +
  4. +
+ +

Uploading a video

+

Videos must be hosted at a publicly accessible URL. The tool uploads the video to TikTok's servers asynchronously — use tiktok_get_video_status to poll for completion.

+ +

Example prompts

+
Upload the video at https://cdn.example.com/demo.mp4 to TikTok
+with caption "Check out our new feature! #AI #productlaunch"
+and set privacy to PUBLIC_TO_EVERYONE
+
+Check the status of my last TikTok upload
+ +
+ TikTok sandbox vs. production + TikTok's Content Posting API requires app approval for production use. New apps start in sandbox mode (videos published to a test account only). Apply for production access through the TikTok developer portal. +
+ + +

💬 WhatsApp

+ +

What you can do

+ + +

Prerequisites

+

You need a WhatsApp Business Account and a phone number registered through the Meta Business Manager.

+ +

Connecting WhatsApp

+
    +
  1. +
    + Get your credentials from Meta + In Meta for Developers → Your App → WhatsApp → API Setup: +
      +
    • Phone Number ID — the ID of your registered number
    • +
    • Business Account ID — your WABA ID
    • +
    • Permanent Access Token — generate from System Users in Business Manager
    • +
    +
    +
  2. +
  3. +
    + Enter credentials in dashboard + Open SquareMCP dashboard → WhatsApp → Connect → paste all three values → Save. +
    +
  4. +
  5. +
    + Configure the webhook (optional — for inbound messages) + In Meta for Developers → Webhooks, set the callback URL to https://hermes.squaremcp.com/webhook/whatsapp and the verify token to your WA_VERIFY_TOKEN (ask support for this). Subscribe to the messages field. + Then configure a forwarding URL in SquareMCP dashboard → Webhooks so inbound messages reach your server. +
    +
  6. +
+ +

Sending messages

+

Freeform message (within 24-hour session window)

+
Send a WhatsApp message to +447911123456 saying:
+"Hi! Your order #12345 has shipped and will arrive tomorrow."
+ +

Template message (anytime)

+
List my WhatsApp message templates
+
+Send the "order_confirmation" template to +447911123456
+with variables: order_id=12345, delivery_date="14 May 2026"
+ +
+ 24-hour rule + WhatsApp only allows freeform messages within 24 hours of a customer contacting you first. Outside that window, use approved template messages. +
+ + +

📷 Instagram

+ +

What you can do

+ + +

Connecting Instagram

+

Instagram posting requires a Business or Creator account connected to a Facebook Page.

+ +
    +
  1. +
    + Get a Graph API access token + Open the Graph API Explorer, select your app, and request instagram_basic, instagram_content_publish, and pages_read_engagement permissions. Generate a User Access Token and exchange it for a long-lived token. +
    +
  2. +
  3. +
    + Find your Business Account ID + Call GET /me/accounts then GET /{page_id}?fields=instagram_business_account to find your Instagram Business Account ID. +
    +
  4. +
  5. +
    + Enter credentials in dashboard + SquareMCP dashboard → Instagram → Connect → paste the access token and Business Account ID. +
    +
  6. +
+ +

Example prompts

+
Post an Instagram reel from https://cdn.example.com/reel.mp4
+with caption "Behind the scenes of our product shoot 🎬 #startup"
+
+Get my last 10 Instagram posts
+ + +

𝕏 Twitter / X

+ +

Connecting Twitter / X

+
    +
  1. +
    + Create a developer app + Go to developer.twitter.com and create a project and app. Enable OAuth 1.0a with Read and Write permissions. +
    +
  2. +
  3. +
    + Generate all four credentials + In your app → Keys and tokens: copy the API Key, API Secret, Access Token, and Access Token Secret (all four are required). +
    +
  4. +
  5. +
    + Enter credentials in dashboard + SquareMCP dashboard → Twitter / X → Connect → paste all four values. +
    +
  6. +
+ +

Example prompts

+
Tweet: "We just shipped our v2 release 🚀 Read the changelog at squaremcp.com"
+
+Upload the video at /tmp/demo.mp4 and tweet "Watch our new feature demo!"
+
+Search Twitter for tweets mentioning "SquareMCP"
+ + +

✈️ Telegram

+ +

Connecting Telegram

+
    +
  1. +
    + Create a bot with BotFather + Open Telegram and message @BotFather. Send /newbot, choose a name and username, and copy the API token it provides (format: 123456789:ABCdef...). +
    +
  2. +
  3. +
    + Enter the token in dashboard + SquareMCP dashboard → Telegram → Connect → paste the bot token. +
    +
  4. +
  5. +
    + Add your bot to a chat or group + Bots can only send messages to chats they're a member of. Add your bot to the target chat, then use telegram_get_updates to find the chat ID. +
    +
  6. +
+ +

Example prompts

+
Send a Telegram message to chat ID -1001234567890 saying "Daily report ready!"
+
+Get the latest Telegram messages in my channel
+
+ + + diff --git a/docs/styles.css b/docs/styles.css new file mode 100644 index 0000000..35aeb18 --- /dev/null +++ b/docs/styles.css @@ -0,0 +1,293 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0f0f10; + --surface: #1a1a1b; + --surface-hover: #222223; + --border: #2a2a2b; + --text: #e5e5e5; + --text-secondary: #888; + --accent: #10a37f; + --accent-hover: #0d8c6d; + --code-bg: #161617; + --radius: 10px; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── Nav ── */ +.site-nav { + background: var(--surface); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} + +.nav-inner { + max-width: 1100px; + margin: 0 auto; + padding: 0 24px; + height: 56px; + display: flex; + align-items: center; + gap: 32px; +} + +.nav-logo { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; + font-size: 16px; + color: var(--text); + text-decoration: none; +} + +.nav-logo-mark { + width: 28px; + height: 28px; + background: linear-gradient(135deg, var(--accent), #0d8c6d); + border-radius: 7px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 700; + font-size: 14px; +} + +.nav-links { + display: flex; + gap: 4px; + flex: 1; +} + +.nav-links a { + padding: 6px 12px; + border-radius: 6px; + font-size: 14px; + color: var(--text-secondary); + transition: all 0.15s; +} + +.nav-links a:hover { color: var(--text); background: var(--surface-hover); text-decoration: none; } +.nav-links a.active { color: var(--text); background: var(--bg); } + +.nav-cta { + background: var(--accent); + color: #fff !important; + padding: 7px 16px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + transition: background 0.15s; +} +.nav-cta:hover { background: var(--accent-hover) !important; text-decoration: none !important; } + +/* ── Layout ── */ +.page { + max-width: 860px; + margin: 0 auto; + padding: 56px 24px 96px; +} + +.hero { + margin-bottom: 48px; +} + +.hero h1 { + font-size: 36px; + font-weight: 700; + line-height: 1.25; + margin-bottom: 12px; +} + +.hero p { + font-size: 18px; + color: var(--text-secondary); + max-width: 620px; +} + +/* ── Section headers ── */ +h2 { + font-size: 22px; + font-weight: 600; + margin: 48px 0 16px; + padding-top: 8px; + border-top: 1px solid var(--border); +} + +h3 { + font-size: 17px; + font-weight: 600; + margin: 28px 0 10px; + color: var(--text); +} + +p { margin-bottom: 14px; color: var(--text); } + +ul, ol { padding-left: 20px; margin-bottom: 14px; } +li { margin-bottom: 6px; } + +/* ── Code ── */ +pre { + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px 24px; + overflow-x: auto; + margin: 16px 0; + position: relative; +} + +code { + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace; + font-size: 13px; + line-height: 1.7; + color: #d4d4d4; +} + +p code, li code { + background: #2a2a2b; + padding: 2px 7px; + border-radius: 5px; + font-size: 12.5px; + color: #e5e5e5; +} + +.code-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-secondary); + margin-bottom: 6px; + margin-top: 20px; +} + +/* Syntax colours */ +.kw { color: #569cd6; } +.str { color: #ce9178; } +.cmt { color: #6a9955; } +.fn { color: #dcdcaa; } +.num { color: #b5cea8; } +.cls { color: #4ec9b0; } + +/* ── Callout ── */ +.callout { + background: rgba(16, 163, 127, 0.08); + border: 1px solid rgba(16, 163, 127, 0.25); + border-radius: var(--radius); + padding: 16px 20px; + margin: 20px 0; + font-size: 14px; +} + +.callout-warn { + background: rgba(245, 158, 11, 0.08); + border-color: rgba(245, 158, 11, 0.25); +} + +.callout strong { display: block; margin-bottom: 4px; } + +/* ── Steps ── */ +.steps { counter-reset: step; margin: 0; padding: 0; list-style: none; } + +.steps li { + counter-increment: step; + display: flex; + gap: 16px; + margin-bottom: 28px; +} + +.steps li::before { + content: counter(step); + background: var(--accent); + color: #fff; + width: 26px; + height: 26px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + flex-shrink: 0; + margin-top: 2px; +} + +.steps li > div { flex: 1; } +.steps li strong { display: block; margin-bottom: 4px; font-size: 15px; } + +/* ── Cards ── */ +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 14px; + margin: 20px 0; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + transition: border-color 0.15s; +} + +.card:hover { border-color: #3a3a3b; } +.card h4 { font-size: 15px; margin-bottom: 6px; } +.card p { font-size: 13px; color: var(--text-secondary); margin: 0; } + +/* ── Tab switcher ── */ +.tabs { display: flex; gap: 4px; margin-bottom: -1px; } +.tab { + padding: 8px 16px; + background: transparent; + border: 1px solid transparent; + border-bottom: none; + border-radius: 8px 8px 0 0; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; +} +.tab.active { background: var(--code-bg); border-color: var(--border); color: var(--text); } +.tab-content { display: none; } +.tab-content.active { display: block; } +.tab-panel { border: 1px solid var(--border); border-radius: 0 var(--radius) var(--radius) var(--radius); } +.tab-panel pre { border: none; border-radius: 0 var(--radius) var(--radius) var(--radius); margin: 0; } + +/* ── Badge ── */ +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.badge-green { background: rgba(16,163,127,0.15); color: var(--accent); } +.badge-blue { background: rgba(59,130,246,0.15); color: #60a5fa; } +.badge-amber { background: rgba(245,158,11,0.15); color: #f59e0b; } + +/* ── Platform icon ── */ +.platform-icon { display: inline-flex; align-items: center; gap: 8px; font-weight: 600; } +.icon { width: 24px; height: 24px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; } + +@media (max-width: 640px) { + .hero h1 { font-size: 26px; } + .nav-links { display: none; } + pre { padding: 14px 16px; } +} diff --git a/package-lock.json b/package-lock.json index c9a69ff..70a9f0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,11 +28,107 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.0.0", "@types/nodemailer": "^6.4.0", + "@vitest/coverage-v8": "^4.1.6", "pixelmatch": "^7.1.0", "playwright": "^1.59.1", "pngjs": "^7.0.0", "tsx": "^4.0.0", - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "vitest": "^4.1.6" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -489,6 +585,34 @@ "hono": "^4" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.27.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", @@ -814,6 +938,35 @@ "node": ">= 0.6" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "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.129.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", + "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -892,6 +1045,288 @@ "@redis/client": "^5.12.1" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", + "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", + "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", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", + "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", + "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", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", + "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", + "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", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", + "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", + "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", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", + "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", + "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", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", + "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", + "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", + "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", + "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", + "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", + "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", + "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", + "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", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", + "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", + "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", + "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", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", + "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", + "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", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", + "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", + "dev": true, + "license": "MIT" + }, + "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.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/bcryptjs": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", @@ -910,6 +1345,17 @@ "@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", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -939,6 +1385,20 @@ "@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.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", @@ -1063,6 +1523,150 @@ "@types/node": "*" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz", + "integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.6", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.6", + "vitest": "4.1.6" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", + "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", + "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.6", + "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.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", + "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.6", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", + "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.6", + "@vitest/utils": "4.1.6", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", + "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.6", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@zone-eu/mailsplit": { "version": "5.4.8", "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", @@ -1126,6 +1730,28 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "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/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -1236,6 +1862,16 @@ "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/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -1266,6 +1902,13 @@ "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", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -1368,6 +2011,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "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/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1445,6 +2098,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1505,6 +2165,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "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", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1535,6 +2205,16 @@ "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": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -1621,6 +2301,24 @@ ], "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": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -1752,6 +2450,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1785,6 +2493,13 @@ "node": ">=16.9.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1901,6 +2616,45 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jose": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.0.tgz", @@ -1910,6 +2664,13 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -2007,6 +2768,267 @@ "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", "license": "MIT" }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -2070,6 +3092,44 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "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/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2195,6 +3255,25 @@ "node": ">=8.0.0" } }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -2234,6 +3313,17 @@ "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-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -2288,6 +3378,33 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/pino": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", @@ -2404,6 +3521,35 @@ "node": ">=14.19.0" } }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "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/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -2538,6 +3684,40 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rolldown": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", + "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.129.0", + "@rolldown/pluginutils": "1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0", + "@rolldown/binding-darwin-arm64": "1.0.0", + "@rolldown/binding-darwin-x64": "1.0.0", + "@rolldown/binding-freebsd-x64": "1.0.0", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", + "@rolldown/binding-linux-arm64-gnu": "1.0.0", + "@rolldown/binding-linux-arm64-musl": "1.0.0", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0", + "@rolldown/binding-linux-s390x-gnu": "1.0.0", + "@rolldown/binding-linux-x64-gnu": "1.0.0", + "@rolldown/binding-linux-x64-musl": "1.0.0", + "@rolldown/binding-openharmony-arm64": "1.0.0", + "@rolldown/binding-wasm32-wasi": "1.0.0", + "@rolldown/binding-win32-arm64-msvc": "1.0.0", + "@rolldown/binding-win32-x64-msvc": "1.0.0" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2778,6 +3958,13 @@ "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/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -2811,6 +3998,16 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -2835,6 +4032,13 @@ "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" } }, + "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", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2844,6 +4048,26 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/thread-stream": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", @@ -2856,6 +4080,50 @@ "node": ">=20" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2865,6 +4133,14 @@ "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/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -2945,6 +4221,174 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.0.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", + "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.0", + "tinyglobby": "^0.2.16" + }, + "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.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", + "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.6", + "@vitest/mocker": "4.1.6", + "@vitest/pretty-format": "4.1.6", + "@vitest/runner": "4.1.6", + "@vitest/snapshot": "4.1.6", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "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.6", + "@vitest/browser-preview": "4.1.6", + "@vitest/browser-webdriverio": "4.1.6", + "@vitest/coverage-istanbul": "4.1.6", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2960,6 +4404,23 @@ "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", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 238f9aa..7b4c324 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "test:product-site:e2e": "node product/site/e2e-test.mjs", "test:product-site:verify": "node product/site/verify.mjs", "test:product-site:cleanup": "node product/site/cleanup-test-submissions.mjs", - "deploy:product-site:verify": "bash product/site/deploy-and-verify.sh" + "deploy:product-site:verify": "bash product/site/deploy-and-verify.sh", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", @@ -36,10 +38,12 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.0.0", "@types/nodemailer": "^6.4.0", + "@vitest/coverage-v8": "^4.1.6", "pixelmatch": "^7.1.0", "playwright": "^1.59.1", "pngjs": "^7.0.0", "tsx": "^4.0.0", - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "vitest": "^4.1.6" } } diff --git a/product/app/app.js b/product/app/app.js index 43fb3d2..9870da2 100644 --- a/product/app/app.js +++ b/product/app/app.js @@ -24,10 +24,14 @@ const modalClose = document.querySelector('.modal-close'); const modalBackdrop = document.querySelector('.modal-backdrop'); const navLinks = document.querySelectorAll('.nav-link'); const platformGrid = document.querySelector('.platform-grid'); +const analyticsSection = document.getElementById('analytics-section'); const invoicesSection = document.getElementById('invoices-section'); const adminSection = document.getElementById('admin-section'); const adminNav = document.getElementById('admin-nav'); +let platformChartInstance = null; +let dailyChartInstance = null; + // Platform config const PLATFORM_CONFIG = { tiktok: { @@ -178,8 +182,14 @@ navLinks.forEach(link => { platformGrid.classList.toggle('hidden', view !== 'platforms'); document.querySelector('.welcome').classList.toggle('hidden', view !== 'platforms'); document.querySelector('.usage-bar').classList.toggle('hidden', view !== 'platforms'); + analyticsSection.classList.toggle('hidden', view !== 'analytics'); invoicesSection.classList.toggle('hidden', view !== 'invoices'); adminSection.classList.toggle('hidden', view !== 'admin'); + document.getElementById('webhooks-section').classList.toggle('hidden', view !== 'webhooks'); + if (view === 'analytics') loadAnalytics(); + if (view === 'invoices') loadInvoices(); + if (view === 'admin') loadAdminPanel(); + if (view === 'webhooks') loadWebhookConfig(); }); }); @@ -227,6 +237,11 @@ logoutBtn.addEventListener('click', async () => { showLogin(); }); +// Connect MCP Client — start the browser OAuth flow +document.getElementById('connect-mcp-btn')?.addEventListener('click', () => { + window.open(`${API_BASE}/oauth/connect-mcp`, '_blank', 'width=560,height=600,noopener'); +}); + // Password reset request resetRequestForm?.addEventListener('submit', async (e) => { e.preventDefault(); @@ -291,21 +306,35 @@ loginForm.appendChild(forgotLink); // Connection status async function updateConnectionStatus() { try { - const data = await apiGet('/api/connections'); - const connections = data.connections || {}; + const [connData, healthData] = await Promise.all([ + apiGet('/api/connections').catch(() => ({ connections: {} })), + apiGet('/api/health/platforms').catch(() => ({ health: [] })), + ]); + const connections = connData.connections || {}; + const healthMap = {}; + (healthData.health || []).forEach(h => { healthMap[h.platform] = h.status; }); + document.querySelectorAll('.platform-card').forEach(card => { const platform = card.dataset.platform; const badge = card.querySelector('.status-badge'); const btn = card.querySelector('.btn-connect'); - if (connections[platform]) { + const health = healthMap[platform]; + + if (health === 'healthy') { badge.textContent = 'Connected'; - badge.classList.remove('disconnected'); - badge.classList.add('connected'); + badge.className = 'status-badge connected'; + btn.textContent = 'Manage'; + } else if (health === 'expired') { + badge.textContent = 'Token expired'; + badge.className = 'status-badge expired'; + btn.textContent = 'Reconnect'; + } else if (connections[platform]) { + badge.textContent = 'Connected'; + badge.className = 'status-badge connected'; btn.textContent = 'Manage'; } else { badge.textContent = 'Not connected'; - badge.classList.remove('connected'); - badge.classList.add('disconnected'); + badge.className = 'status-badge disconnected'; btn.textContent = 'Connect'; } }); @@ -364,6 +393,93 @@ async function loadInvoices() { } } +// Analytics +const PLATFORM_COLORS = { + email: '#ea4335', linkedin: '#0a66c2', twitter: '#000000', + instagram: '#e1306c', facebook: '#1877f2', tiktok: '#010101', + whatsapp: '#25d366', telegram: '#0088cc', discord: '#5865f2', + snapchat: '#fffc00', obsidian: '#7c3aed', +}; + +async function loadAnalytics() { + const emptyEl = document.getElementById('analytics-empty'); + try { + const [usageData, dailyData] = await Promise.all([ + apiGet('/api/usage'), + apiGet('/api/usage/daily'), + ]); + + const breakdown = usageData.breakdown || {}; + const daily = dailyData.daily || []; + + const hasData = Object.keys(breakdown).length > 0 || daily.length > 0; + emptyEl.classList.toggle('hidden', hasData); + document.querySelector('.charts-grid').classList.toggle('hidden', !hasData); + + if (!hasData) return; + + // Platform breakdown chart + const platformLabels = Object.keys(breakdown); + const platformCounts = platformLabels.map(p => breakdown[p]); + const platformColors = platformLabels.map(p => PLATFORM_COLORS[p] || '#888'); + + if (platformChartInstance) platformChartInstance.destroy(); + platformChartInstance = new Chart(document.getElementById('platform-chart'), { + type: 'bar', + data: { + labels: platformLabels, + datasets: [{ + label: 'Calls', + data: platformCounts, + backgroundColor: platformColors, + borderRadius: 6, + }], + }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + x: { ticks: { color: '#888' }, grid: { color: '#2a2a2b' } }, + y: { ticks: { color: '#e5e5e5' }, grid: { display: false } }, + }, + }, + }); + + // Daily activity chart + const dailyLabels = daily.map(d => d.date); + const dailyCounts = daily.map(d => Number(d.count)); + + if (dailyChartInstance) dailyChartInstance.destroy(); + dailyChartInstance = new Chart(document.getElementById('daily-chart'), { + type: 'bar', + data: { + labels: dailyLabels, + datasets: [{ + label: 'Calls', + data: dailyCounts, + backgroundColor: '#10a37f', + borderRadius: 4, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + x: { ticks: { color: '#888', maxRotation: 45 }, grid: { color: '#2a2a2b' } }, + y: { ticks: { color: '#888' }, grid: { color: '#2a2a2b' }, beginAtZero: true }, + }, + }, + }); + } catch { + emptyEl.classList.remove('hidden'); + emptyEl.querySelector('p').textContent = 'Failed to load analytics.'; + document.querySelector('.charts-grid').classList.add('hidden'); + } +} + // Admin panel async function loadAdminPanel() { try { @@ -528,5 +644,51 @@ async function checkSession() { } } +// Webhook config +async function loadWebhookConfig() { + try { + const data = await apiGet('/api/webhooks/config'); + const display = document.getElementById('webhook-url-display'); + const deleteBtn = document.getElementById('webhook-delete-btn'); + const input = document.getElementById('webhook-url-input'); + if (data.webhookUrl) { + display.textContent = data.webhookUrl; + deleteBtn.classList.remove('hidden'); + input.value = data.webhookUrl; + } else { + display.textContent = 'No webhook configured'; + deleteBtn.classList.add('hidden'); + input.value = ''; + } + document.getElementById('webhook-secret-box').classList.add('hidden'); + } catch { + // ignore + } +} + +document.getElementById('webhook-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const url = document.getElementById('webhook-url-input').value.trim(); + const data = await apiPost('/api/webhooks/config', { webhook_url: url }); + if (data.error) { alert(data.error); return; } + document.getElementById('webhook-url-display').textContent = data.webhookUrl; + document.getElementById('webhook-delete-btn').classList.remove('hidden'); + if (data.webhookSecret) { + document.getElementById('webhook-secret-value').textContent = data.webhookSecret; + document.getElementById('webhook-secret-box').classList.remove('hidden'); + } +}); + +document.getElementById('webhook-delete-btn')?.addEventListener('click', async () => { + if (!confirm('Remove webhook? This cannot be undone.')) return; + await fetch(`${API_BASE}/api/webhooks/config`, { method: 'DELETE', credentials: 'include' }); + loadWebhookConfig(); +}); + +window.copyWebhookSecret = () => { + const val = document.getElementById('webhook-secret-value').textContent; + navigator.clipboard.writeText(val).then(() => alert('Secret copied!')); +}; + // Init checkSession(); diff --git a/product/app/index.html b/product/app/index.html index 232dfa3..83ff784 100644 --- a/product/app/index.html +++ b/product/app/index.html @@ -80,7 +80,9 @@
@@ -92,6 +94,7 @@

Connect your platforms

Link your social accounts to publish, analyze, and manage content from one place.

+
@@ -203,6 +206,24 @@
+ + + + @@ -225,6 +269,7 @@ + diff --git a/product/app/styles.css b/product/app/styles.css index 265a2f5..9d54671 100644 --- a/product/app/styles.css +++ b/product/app/styles.css @@ -315,6 +315,11 @@ body { color: var(--text-secondary); } +.status-badge.expired { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + /* Modal */ .modal { position: fixed; @@ -601,6 +606,144 @@ body { font-size: 11px; } +/* Analytics */ +.analytics-section { + margin-top: 24px; +} + +.section-title { + font-size: 18px; + margin-bottom: 4px; +} + +.section-subtitle { + color: var(--text-secondary); + font-size: 13px; + margin-bottom: 20px; +} + +.charts-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +@media (max-width: 700px) { + .charts-grid { grid-template-columns: 1fr; } +} + +.chart-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; +} + +.chart-card h4 { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 16px; +} + +.chart-container { + position: relative; + height: 220px; +} + +.analytics-empty { + padding: 40px 20px; + text-align: center; + color: var(--text-secondary); + font-size: 14px; +} + +/* Webhooks */ +.webhooks-section { + margin-top: 24px; +} + +.webhook-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; + max-width: 640px; +} + +.webhook-status-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.webhook-url-display { + flex: 1; + font-size: 14px; + color: var(--text-secondary); + word-break: break-all; +} + +.webhook-form { + display: flex; + gap: 10px; + margin-bottom: 16px; +} + +.webhook-form input { + flex: 1; + padding: 10px 14px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + font-size: 14px; + outline: none; +} + +.webhook-form input:focus { + border-color: var(--accent); +} + +.webhook-secret-box { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px; + margin-bottom: 16px; +} + +.webhook-secret-label { + font-size: 12px; + color: #f59e0b; + margin-bottom: 8px; +} + +.webhook-secret-value { + font-family: 'SF Mono', monospace; + font-size: 12px; + word-break: break-all; + margin-bottom: 8px; + color: var(--text); +} + +.webhook-instructions { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.6; +} + +.webhook-instructions code { + background: #2a2a2b; + padding: 2px 6px; + border-radius: 4px; + font-family: 'SF Mono', monospace; + font-size: 12px; +} + /* Password reset */ .success-msg { color: var(--accent); diff --git a/src/billing/cron.test.ts b/src/billing/cron.test.ts new file mode 100644 index 0000000..5b12556 --- /dev/null +++ b/src/billing/cron.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockRedisSet, mockRedisEval } = vi.hoisted(() => ({ + mockRedisSet: vi.fn(), + mockRedisEval: vi.fn(), +})); + +const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() })); + +const { mockGenerateMonthlyInvoice } = vi.hoisted(() => ({ + mockGenerateMonthlyInvoice: vi.fn(), +})); + +vi.mock('../redis.js', () => ({ + default: { set: mockRedisSet, eval: mockRedisEval }, +})); + +vi.mock('../db.js', () => ({ + getPool: () => ({ query: mockQuery }), +})); + +vi.mock('./invoices.js', () => ({ + generateMonthlyInvoice: (...args: any[]) => mockGenerateMonthlyInvoice(...args), +})); + +import { runInvoiceCron } from './cron.js'; + +const TWO_CUSTOMERS = [[{ id: 'cust-a' }, { id: 'cust-b' }]]; + +beforeEach(() => { + vi.clearAllMocks(); + mockRedisEval.mockResolvedValue(1); + mockGenerateMonthlyInvoice.mockResolvedValue(null); +}); + +describe('runInvoiceCron — Redis lock', () => { + it('runs invoice loop when lock is acquired', async () => { + mockRedisSet.mockResolvedValue('OK'); + mockQuery.mockResolvedValue(TWO_CUSTOMERS); + + await runInvoiceCron(); + + expect(mockRedisSet).toHaveBeenCalledWith( + 'invoice:cron:lock', + expect.any(String), + { NX: true, EX: 3600 } + ); + expect(mockQuery).toHaveBeenCalled(); + }); + + it('skips invoice loop when lock is already held', async () => { + mockRedisSet.mockResolvedValue(null); + + await runInvoiceCron(); + + expect(mockQuery).not.toHaveBeenCalled(); + expect(mockGenerateMonthlyInvoice).not.toHaveBeenCalled(); + }); + + it('releases lock via compare-delete Lua script after success', async () => { + mockRedisSet.mockResolvedValue('OK'); + mockQuery.mockResolvedValue([[{ id: 'cust-1' }]]); + + await runInvoiceCron(); + + expect(mockRedisEval).toHaveBeenCalledWith( + expect.stringContaining('redis.call("get"'), + expect.objectContaining({ keys: ['invoice:cron:lock'], arguments: [expect.any(String)] }) + ); + }); + + it('does NOT release lock when the loop throws', async () => { + mockRedisSet.mockResolvedValue('OK'); + mockQuery.mockRejectedValue(new Error('DB down')); + + await expect(runInvoiceCron()).rejects.toThrow('DB down'); + expect(mockRedisEval).not.toHaveBeenCalled(); + }); +}); + +describe('runInvoiceCron — per-customer error isolation', () => { + it('continues processing remaining customers after one fails', async () => { + mockRedisSet.mockResolvedValue('OK'); + mockQuery.mockResolvedValue([[{ id: 'cust-a' }, { id: 'cust-b' }, { id: 'cust-c' }]]); + + mockGenerateMonthlyInvoice + .mockResolvedValueOnce({ invoice_number: 'SMCP-1', customer_id: 'cust-a' }) + .mockRejectedValueOnce(new Error('Stripe error')) + .mockResolvedValueOnce({ invoice_number: 'SMCP-3', customer_id: 'cust-c' }); + + await runInvoiceCron(); + + expect(mockGenerateMonthlyInvoice).toHaveBeenCalledTimes(3); + expect(mockRedisEval).toHaveBeenCalled(); + }); +}); diff --git a/src/billing/cron.ts b/src/billing/cron.ts new file mode 100644 index 0000000..80041b6 --- /dev/null +++ b/src/billing/cron.ts @@ -0,0 +1,51 @@ +import { randomUUID } from 'crypto'; +import redis from '../redis.js'; +import { getPool } from '../db.js'; +import { generateMonthlyInvoice } from './invoices.js'; + +const LOCK_KEY = 'invoice:cron:lock'; +const LOCK_TTL_SECONDS = 3600; + +// Only releases the lock if the value matches — prevents one replica from +// deleting another's lock after a TTL expiry race. +const COMPARE_DELETE_LUA = ` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end +`; + +export async function runInvoiceCron(): Promise { + const lockValue = randomUUID(); + + const acquired = await redis.set(LOCK_KEY, lockValue, { NX: true, EX: LOCK_TTL_SECONDS }); + if (!acquired) { + console.log('[cron] Another process holds the invoice lock — skipping'); + return; + } + + try { + const [customers] = await getPool().query( + "SELECT id FROM customers WHERE active = TRUE AND plan != 'free'" + ); + + for (const customer of customers) { + try { + const invoice = await generateMonthlyInvoice(customer.id); + if (invoice) { + console.log(`[cron] Generated invoice ${invoice.invoice_number} for customer ${customer.id}`); + } + } catch (err) { + // One customer failing must not abort the rest + console.error(`[cron] Failed for customer ${customer.id}:`, err); + } + } + + await redis.eval(COMPARE_DELETE_LUA, { keys: [LOCK_KEY], arguments: [lockValue] }); + } catch (err) { + // Leave the lock in place on unexpected failure — TTL releases it after 1 hour + console.error('[cron] Invoice cron fatal error:', err); + throw err; + } +} diff --git a/src/billing/invoices.test.ts b/src/billing/invoices.test.ts new file mode 100644 index 0000000..41d6b98 --- /dev/null +++ b/src/billing/invoices.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import nodemailer from 'nodemailer'; + +const mockQuery = vi.fn(); +vi.mock('../db.js', () => ({ + getPool: () => ({ query: mockQuery }), +})); +vi.mock('nodemailer', () => ({ + default: { createTransport: vi.fn() }, +})); + +import { + createInvoice, + generateMonthlyInvoice, + getInvoiceByNumber, + emailInvoice, + type Invoice, +} from './invoices.js'; + +const baseInvoice: Invoice = { + id: 1, + customer_id: 'cust-1', + invoice_number: 'SMCP-20260501-aabb1122', + amount: 25.0, + currency: 'USD', + status: 'draft', + period_start: '2026-04-01', + period_end: '2026-04-30', + line_items: [{ description: 'email actions', quantity: 500, unit_price: 0.05, amount: 25.0 }], + sent_at: null, + paid_at: null, + created_at: '2026-05-01T00:00:00Z', + constructor: { name: 'RowDataPacket' } as any, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ── generateInvoiceNumber format ───────────────────────────────────────────── + +describe('generateMonthlyInvoice — billing period', () => { + it('queries previous month, not current', async () => { + mockQuery.mockResolvedValueOnce([[]]); // usage query returns empty + await generateMonthlyInvoice('cust-1'); + const sql: string = mockQuery.mock.calls[0][0]; + // Must reference DATE_SUB for the start of the previous month + expect(sql).toContain('DATE_SUB'); + // Must NOT use a plain NOW() as the lower bound (that would be current month) + expect(sql).not.toMatch(/>=\s*DATE_FORMAT\(NOW\(\)/); + }); + + it('returns null when customer has zero usage', async () => { + mockQuery.mockResolvedValueOnce([[]]); // no usage rows + const result = await generateMonthlyInvoice('cust-1'); + expect(result).toBeNull(); + }); + + it('creates invoice for previous month period', async () => { + const now = new Date(); + const expectedStart = new Date(now.getFullYear(), now.getMonth() - 1, 1) + .toISOString().slice(0, 10); + + mockQuery + .mockResolvedValueOnce([[{ platform: 'email', count: 100 }]]) // usage + .mockResolvedValueOnce([[]]) // insert + .mockResolvedValueOnce([[{ ...baseInvoice, period_start: expectedStart }]]); // select after insert + + const invoice = await generateMonthlyInvoice('cust-1'); + expect(invoice?.period_start).toBe(expectedStart); + }); +}); + +// ── Invoice number format ───────────────────────────────────────────────────── + +describe('createInvoice — invoice number', () => { + it('generates hex suffix, not decimal padded', async () => { + mockQuery + .mockResolvedValueOnce([[]]) // insert + .mockResolvedValueOnce([[baseInvoice]]); // select + + await createInvoice('cust-1', 25, baseInvoice.line_items, '2026-04-01', '2026-04-30'); + + const insertSql: string = mockQuery.mock.calls[0][0]; + const invoiceNumber: string = mockQuery.mock.calls[0][1][1]; + // Should be SMCP-YYYYMMDD-<8 hex chars> + expect(invoiceNumber).toMatch(/^SMCP-\d{8}-[0-9a-f]{8}$/); + }); +}); + +// ── Idempotency ─────────────────────────────────────────────────────────────── + +describe('createInvoice — idempotency', () => { + it('returns existing invoice on uq_customer_period duplicate', async () => { + const dupError = Object.assign(new Error("Duplicate entry 'cust-1-2026-04-01' for key 'invoices.uq_customer_period'"), { errno: 1062 }); + mockQuery + .mockRejectedValueOnce(dupError) // INSERT throws 1062 + .mockResolvedValueOnce([[baseInvoice]]); // SELECT existing + + const invoice = await createInvoice('cust-1', 25, baseInvoice.line_items, '2026-04-01', '2026-04-30'); + expect(invoice.invoice_number).toBe(baseInvoice.invoice_number); + expect(invoice.customer_id).toBe('cust-1'); + }); + + it('re-throws 1062 for other constraints (e.g. duplicate invoice_number)', async () => { + const dupError = Object.assign(new Error("Duplicate entry 'SMCP-...' for key 'invoices.invoice_number'"), { errno: 1062 }); + mockQuery.mockRejectedValueOnce(dupError); + + await expect(createInvoice('cust-1', 25, baseInvoice.line_items, '2026-04-01', '2026-04-30')) + .rejects.toThrow('invoice_number'); + }); +}); + +// ── line_items JSON normalization ───────────────────────────────────────────── + +describe('getInvoiceByNumber — line_items normalization', () => { + it('parses line_items when returned as JSON string', async () => { + const rawInvoice = { + ...baseInvoice, + line_items: JSON.stringify(baseInvoice.line_items) as any, + }; + mockQuery.mockResolvedValueOnce([[rawInvoice]]); + + const invoice = await getInvoiceByNumber('SMCP-20260501-aabb1122'); + expect(Array.isArray(invoice?.line_items)).toBe(true); + expect(invoice?.line_items[0].description).toBe('email actions'); + }); + + it('leaves line_items unchanged when already an array', async () => { + mockQuery.mockResolvedValueOnce([[baseInvoice]]); + const invoice = await getInvoiceByNumber('SMCP-20260501-aabb1122'); + expect(Array.isArray(invoice?.line_items)).toBe(true); + }); +}); + +// ── emailInvoice ───────────────────────────────────────────────────────────── + +describe('emailInvoice', () => { + it('sends mail with invoice_number in subject and HTML body', async () => { + const sendMail = vi.fn().mockResolvedValue({ messageId: '' }); + vi.mocked(nodemailer.createTransport).mockReturnValue({ sendMail } as any); + + await emailInvoice(baseInvoice, 'customer@example.com'); + + expect(sendMail).toHaveBeenCalledOnce(); + const mailArgs = sendMail.mock.calls[0][0]; + expect(mailArgs.to).toBe('customer@example.com'); + expect(mailArgs.subject).toContain(baseInvoice.invoice_number); + expect(mailArgs.html).toContain(baseInvoice.invoice_number); + }); +}); diff --git a/src/billing/invoices.ts b/src/billing/invoices.ts index 8de1a09..e0b370a 100644 --- a/src/billing/invoices.ts +++ b/src/billing/invoices.ts @@ -1,3 +1,5 @@ +import { randomBytes } from 'crypto'; +import nodemailer from 'nodemailer'; import { getPool } from '../db.js'; import type { RowDataPacket } from 'mysql2'; @@ -24,10 +26,16 @@ export interface Invoice extends RowDataPacket { } function generateInvoiceNumber(): string { - const prefix = 'SMCP'; const date = new Date().toISOString().slice(0, 10).replace(/-/g, ''); - const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0'); - return `${prefix}-${date}-${random}`; + const suffix = randomBytes(4).toString('hex'); + return `SMCP-${date}-${suffix}`; +} + +function normalizeInvoice(invoice: Invoice): Invoice { + if (typeof invoice.line_items === 'string') { + invoice.line_items = JSON.parse(invoice.line_items); + } + return invoice; } export async function createInvoice( @@ -38,16 +46,27 @@ export async function createInvoice( periodEnd: string ): Promise { const invoiceNumber = generateInvoiceNumber(); - await getPool().query( - `INSERT INTO invoices (customer_id, invoice_number, amount, line_items, period_start, period_end) - VALUES (?, ?, ?, ?, ?, ?)`, - [customerId, invoiceNumber, amount, JSON.stringify(lineItems), periodStart, periodEnd] - ); + try { + await getPool().query( + `INSERT INTO invoices (customer_id, invoice_number, amount, line_items, period_start, period_end) + VALUES (?, ?, ?, ?, ?, ?)`, + [customerId, invoiceNumber, amount, JSON.stringify(lineItems), periodStart, periodEnd] + ); + } catch (err: any) { + if (err.errno === 1062 && err.message.includes('uq_customer_period')) { + const [rows] = await getPool().query( + 'SELECT * FROM invoices WHERE customer_id = ? AND period_start = ?', + [customerId, periodStart] + ); + return normalizeInvoice(rows[0]); + } + throw err; + } const [rows] = await getPool().query( 'SELECT * FROM invoices WHERE invoice_number = ?', [invoiceNumber] ); - return rows[0]; + return normalizeInvoice(rows[0]); } export async function getCustomerInvoices(customerId: string): Promise { @@ -55,7 +74,7 @@ export async function getCustomerInvoices(customerId: string): Promise { @@ -63,7 +82,7 @@ export async function getInvoiceByNumber(invoiceNumber: string): Promise { @@ -80,12 +99,45 @@ export async function markInvoicePaid(invoiceNumber: string): Promise { ); } +export async function emailInvoice(invoice: Invoice, toEmail: string): Promise { + const transport = nodemailer.createTransport({ + host: process.env.FETCHERPAY_SMTP_HOST ?? 'mail.fetcherpay.com', + port: parseInt(process.env.FETCHERPAY_SMTP_PORT ?? '30587', 10), + secure: false, + auth: { + user: process.env.BILLING_EMAIL ?? process.env.FETCHERPAY_EMAIL, + pass: process.env.BILLING_PASSWORD ?? process.env.FETCHERPAY_PASSWORD, + }, + tls: { rejectUnauthorized: false }, + }); + await transport.sendMail({ + from: process.env.BILLING_FROM ?? 'billing@squaremcp.com', + to: toEmail, + subject: `Invoice ${invoice.invoice_number} from SquareMCP`, + html: ` +

Invoice ${invoice.invoice_number}

+

Billing period: ${invoice.period_start} – ${invoice.period_end}

+

Amount due: $${invoice.amount} ${invoice.currency}

+ + + ${invoice.line_items.map((li) => + `` + ).join('')} +
DescriptionQtyUnit priceAmount
${li.description}${li.quantity}$${li.unit_price}$${li.amount}
+ `, + }); +} + export async function generateMonthlyInvoice(customerId: string): Promise { + const now = new Date(); + const prevMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const prevMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0); + const [usageRows] = await getPool().query( `SELECT platform, COUNT(*) as count FROM usage_logs WHERE customer_id = ? - AND created_at >= DATE_FORMAT(NOW(), '%Y-%m-01') - AND created_at < DATE_FORMAT(DATE_ADD(NOW(), INTERVAL 1 MONTH), '%Y-%m-01') + AND created_at >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 MONTH), '%Y-%m-01') + AND created_at < DATE_FORMAT(NOW(), '%Y-%m-01') GROUP BY platform`, [customerId] ); @@ -97,7 +149,7 @@ export async function generateMonthlyInvoice(customerId: string): Promise console.error('[billing] Redis connect error:', err)); +import redis from '../redis.js'; export interface Customer { id: string; diff --git a/src/billing/plans.ts b/src/billing/plans.ts index af84692..43b657b 100644 --- a/src/billing/plans.ts +++ b/src/billing/plans.ts @@ -20,11 +20,11 @@ export const PLANS: Record = { growth: { name: 'Growth', monthlyCallLimit: 10000, - platforms: ['email', 'obsidian', 'whatsapp', 'telegram', 'discord', 'instagram', 'linkedin', 'twitter'], + platforms: ['email', 'obsidian', 'whatsapp', 'telegram', 'discord', 'instagram', 'linkedin', 'twitter', 'tiktok', 'facebook', 'snapchat'], }, enterprise: { name: 'Enterprise', monthlyCallLimit: -1, - platforms: ['email', 'obsidian', 'whatsapp', 'telegram', 'discord', 'instagram', 'linkedin', 'twitter'], + platforms: ['email', 'obsidian', 'whatsapp', 'telegram', 'discord', 'instagram', 'linkedin', 'twitter', 'tiktok', 'facebook', 'snapchat'], }, }; diff --git a/src/billing/usage.test.ts b/src/billing/usage.test.ts new file mode 100644 index 0000000..b920605 --- /dev/null +++ b/src/billing/usage.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { checkLimit } from './usage.js'; + +vi.mock('../db.js', () => ({ + getPool: vi.fn(() => ({ + query: vi.fn().mockResolvedValue([[{ count: 50 }]]), + })), +})); + +describe('checkLimit', () => { + it('always allows enterprise plan regardless of usage', async () => { + const result = await checkLimit('cust-1', 'enterprise'); + expect(result).toEqual({ allowed: true, limit: -1, used: 0 }); + }); + + it('allows when usage is under limit', async () => { + const { getPool } = await import('../db.js'); + vi.mocked(getPool).mockReturnValue({ query: vi.fn().mockResolvedValue([[{ count: 50 }]]) } as any); + const result = await checkLimit('cust-1', 'growth'); + expect(result.allowed).toBe(true); + expect(result.used).toBe(50); + expect(result.limit).toBe(10000); + }); + + it('blocks when usage equals limit', async () => { + const { getPool } = await import('../db.js'); + vi.mocked(getPool).mockReturnValue({ query: vi.fn().mockResolvedValue([[{ count: 1000 }]]) } as any); + const result = await checkLimit('cust-1', 'starter'); + expect(result.allowed).toBe(false); + expect(result.used).toBe(1000); + }); + + it('blocks when usage exceeds limit', async () => { + const { getPool } = await import('../db.js'); + vi.mocked(getPool).mockReturnValue({ query: vi.fn().mockResolvedValue([[{ count: 1001 }]]) } as any); + const result = await checkLimit('cust-1', 'free'); + expect(result.allowed).toBe(false); + }); + + it('does not query DB for enterprise plan', async () => { + const { getPool } = await import('../db.js'); + const querySpy = vi.fn(); + vi.mocked(getPool).mockReturnValue({ query: querySpy } as any); + await checkLimit('cust-1', 'enterprise'); + expect(querySpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/db.ts b/src/db.ts index bad08c4..1c24e5e 100644 --- a/src/db.ts +++ b/src/db.ts @@ -14,23 +14,29 @@ async function ensureColumn( definition: string ): Promise { const [rows] = await db.execute( - ` - SELECT COLUMN_NAME - FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = ? - AND COLUMN_NAME = ? - `, + `SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`, [tableName, columnName] ); - - if (Array.isArray(rows) && rows.length > 0) { - return; - } - + if (Array.isArray(rows) && rows.length > 0) return; await db.execute(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${definition}`); } +async function ensureIndex( + db: mysql.PoolConnection, + tableName: string, + indexName: string, + definition: string +): Promise { + const [rows] = await db.execute( + `SELECT INDEX_NAME FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?`, + [tableName, indexName] + ); + if (Array.isArray(rows) && rows.length > 0) return; + await db.execute(`ALTER TABLE \`${tableName}\` ADD ${definition}`); +} + export function getPool(): mysql.Pool { if (!pool) { throw new Error('Database pool not initialized. Call initDatabase() first.'); @@ -93,7 +99,11 @@ export async function initDatabase(): Promise { await ensureColumn(db, 'oauth_auth_codes', 'scope', 'TEXT NULL'); await ensureColumn(db, 'oauth_auth_codes', 'code_challenge', 'TEXT NULL'); await ensureColumn(db, 'oauth_auth_codes', 'code_challenge_method', 'VARCHAR(20) NULL'); + await ensureColumn(db, 'oauth_auth_codes', 'customer_id', 'VARCHAR(255) NULL'); + await ensureColumn(db, 'oauth_tokens', 'customer_id', 'VARCHAR(255) NULL'); await ensureColumn(db, 'customers', 'password_hash', 'VARCHAR(255) NULL'); + await ensureColumn(db, 'customers', 'webhook_url', 'VARCHAR(512) NULL'); + await ensureColumn(db, 'customers', 'webhook_secret', 'VARCHAR(64) NULL'); await db.execute(` CREATE TABLE IF NOT EXISTS oauth_tokens ( @@ -158,6 +168,18 @@ export async function initDatabase(): Promise { await ensureColumn(db, 'customers', 'role', "ENUM('user','admin') DEFAULT 'user'"); await ensureColumn(db, 'customers', 'reset_token', 'VARCHAR(255) NULL'); await ensureColumn(db, 'customers', 'reset_expires_at', 'TIMESTAMP NULL'); + + // Remove duplicate (customer_id, period_start) rows before adding unique constraint + // Keeps the earliest invoice (lowest id) for each customer+period pair + await db.execute(` + DELETE i1 FROM invoices i1 + INNER JOIN invoices i2 + ON i1.customer_id = i2.customer_id + AND i1.period_start = i2.period_start + AND i1.id > i2.id + `); + await ensureIndex(db, 'invoices', 'uq_customer_period', + 'UNIQUE KEY uq_customer_period (customer_id, period_start)'); } finally { db.release(); } diff --git a/src/imap.ts b/src/imap.ts index 8263b93..e565b8f 100644 --- a/src/imap.ts +++ b/src/imap.ts @@ -1,6 +1,8 @@ import { ImapFlow } from 'imapflow'; +import type { EmailCredentials } from './multitenancy/credential-store.js'; export type Account = 'yahoo' | 'fetcherpay' | 'garfield' | 'sales' | 'leads' | 'founder' | 'gmail'; +export type EmailCtx = Account | EmailCredentials; const FETCHERPAY_IMAP_HOST = process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com'; const FETCHERPAY_IMAP_PORT = parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993'); @@ -15,41 +17,26 @@ function fetcherpayImapConfig(user: string, pass: string) { }; } -function getConfig(account: Account = 'yahoo') { +function getEnvConfig(account: Account = 'yahoo') { switch (account) { case 'fetcherpay': - return fetcherpayImapConfig( - process.env['FETCHERPAY_EMAIL'] as string, - process.env['FETCHERPAY_PASSWORD'] as string, - ); + return fetcherpayImapConfig(process.env['FETCHERPAY_EMAIL']!, process.env['FETCHERPAY_PASSWORD']!); case 'garfield': - return fetcherpayImapConfig( - process.env['GARFIELD_EMAIL'] as string, - process.env['GARFIELD_PASSWORD'] as string, - ); + return fetcherpayImapConfig(process.env['GARFIELD_EMAIL']!, process.env['GARFIELD_PASSWORD']!); case 'sales': - return fetcherpayImapConfig( - process.env['SALES_EMAIL'] as string, - process.env['SALES_PASSWORD'] as string, - ); + return fetcherpayImapConfig(process.env['SALES_EMAIL']!, process.env['SALES_PASSWORD']!); case 'leads': - return fetcherpayImapConfig( - process.env['LEADS_EMAIL'] as string, - process.env['LEADS_PASSWORD'] as string, - ); + return fetcherpayImapConfig(process.env['LEADS_EMAIL']!, process.env['LEADS_PASSWORD']!); case 'founder': - return fetcherpayImapConfig( - process.env['FOUNDER_EMAIL'] as string, - process.env['FOUNDER_PASSWORD'] as string, - ); + return fetcherpayImapConfig(process.env['FOUNDER_EMAIL']!, process.env['FOUNDER_PASSWORD']!); case 'gmail': return { host: 'imap.gmail.com', port: 993, secure: true, auth: { - user: process.env['GMAIL_EMAIL'] as string, - pass: process.env['GMAIL_APP_PASSWORD'] as string, + user: process.env['GMAIL_EMAIL']!, + pass: process.env['GMAIL_APP_PASSWORD']!, }, }; default: @@ -58,15 +45,33 @@ function getConfig(account: Account = 'yahoo') { port: 993, secure: true, auth: { - user: process.env['YAHOO_EMAIL'] as string, - pass: process.env['YAHOO_APP_PASSWORD'] as string, + user: process.env['YAHOO_EMAIL']!, + pass: process.env['YAHOO_APP_PASSWORD']!, }, }; } } -async function withClient(account: Account, fn: (client: ImapFlow) => Promise): Promise { - const client = new ImapFlow(getConfig(account)); +function resolveImapConfig(ctx: EmailCtx) { + if (typeof ctx === 'object') { + return { + host: ctx.host, + port: ctx.port, + secure: ctx.port === 993, + auth: { user: ctx.user, pass: ctx.password }, + tls: { rejectUnauthorized: false }, + }; + } + return getEnvConfig(ctx); +} + +function isGmail(ctx: EmailCtx): boolean { + if (typeof ctx === 'string') return ctx === 'gmail'; + return ctx.host === 'imap.gmail.com'; +} + +async function withClient(ctx: EmailCtx, fn: (client: ImapFlow) => Promise): Promise { + const client = new ImapFlow(resolveImapConfig(ctx)); await client.connect(); try { return await fn(client); @@ -101,7 +106,6 @@ function parseSearchCriteria(query: string): object { const trimmed = query.trim(); if (!trimmed) return { all: true }; - // Parse quoted and unquoted tokens like from:x, subject:y, to:z const tokens: { key: string; value: string }[] = []; const regex = /(\w+):("([^"]*)"|([^\s]+))/g; let m: RegExpExecArray | null; @@ -123,15 +127,9 @@ function parseSearchCriteria(query: string): object { const parts: object[] = []; for (const t of tokens) { switch (t.key) { - case 'from': - parts.push({ from: t.value }); - break; - case 'subject': - parts.push({ subject: t.value }); - break; - case 'to': - parts.push({ to: t.value }); - break; + case 'from': parts.push({ from: t.value }); break; + case 'subject': parts.push({ subject: t.value }); break; + case 'to': parts.push({ to: t.value }); break; case 'after': case 'since': { const d = new Date(t.value); @@ -159,11 +157,11 @@ async function searchInFolder( folder: string, query: string, maxResults: number, - account: Account + ctx: EmailCtx ): Promise { await client.mailboxOpen(folder); - const criteria = account === 'gmail' + const criteria = isGmail(ctx) ? { gmailraw: query } : parseSearchCriteria(query); @@ -200,28 +198,25 @@ async function searchInFolder( export async function searchMessages( query: string, maxResults = 20, - account: Account = 'yahoo', + ctx: EmailCtx = 'yahoo', folder?: string ): Promise { - return withClient(account, async (client) => { + return withClient(ctx, async (client) => { const foldersToSearch: string[] = []; if (folder) { foldersToSearch.push(folder); - } else if (account === 'gmail') { - foldersToSearch.push('INBOX'); } else { foldersToSearch.push('INBOX'); } for (const f of foldersToSearch) { - const results = await searchInFolder(client, f, query, maxResults, account); + const results = await searchInFolder(client, f, query, maxResults, ctx); if (results.length > 0) return results; } - // Fallback for Gmail: search All Mail if INBOX was empty - if (account === 'gmail' && !folder) { - const allMailResults = await searchInFolder(client, '[Gmail]/All Mail', query, maxResults, account); + if (isGmail(ctx) && !folder) { + const allMailResults = await searchInFolder(client, '[Gmail]/All Mail', query, maxResults, ctx); if (allMailResults.length > 0) return allMailResults; } @@ -229,11 +224,10 @@ export async function searchMessages( }); } -export async function readMessage(uid: number, account: Account = 'yahoo', folder = 'INBOX'): Promise { - return withClient(account, async (client) => { - console.log(`[imap] readMessage uid=${uid} account=${account} folder=${folder}`); +export async function readMessage(uid: number, ctx: EmailCtx = 'yahoo', folder = 'INBOX'): Promise { + return withClient(ctx, async (client) => { + console.log(`[imap] readMessage uid=${uid} folder=${folder}`); await client.mailboxOpen(folder); - console.log(`[imap] mailbox opened, fetching uid=${uid}`); let result: FullMessage | null = null; @@ -243,7 +237,6 @@ export async function readMessage(uid: number, account: Account = 'yahoo', folde bodyParts: ['TEXT'], }, { uid: true })) { const env = msg.envelope; - console.log(`[imap] got msg uid=${msg.uid} subject="${env?.subject}"`); const bpKeys = msg.bodyParts ? [...msg.bodyParts.keys()] : []; console.log(`[imap] bodyParts keys:`, JSON.stringify(bpKeys)); @@ -252,10 +245,8 @@ export async function readMessage(uid: number, account: Account = 'yahoo', folde msg.bodyParts?.get('text') ?? msg.bodyParts?.get('TEXT') ?? msg.bodyParts?.get('1'); - console.log(`[imap] textBuf length=${textBuf ? textBuf.length : 'null'}`); const rawBody = textBuf ? textBuf.toString('utf-8') : ''; - const body = rawBody .replace(/]*>[\s\S]*?<\/style>/gi, '') .replace(/]*>[\s\S]*?<\/script>/gi, '') @@ -264,8 +255,6 @@ export async function readMessage(uid: number, account: Account = 'yahoo', folde .trim() .slice(0, 10000); - console.log(`[imap] body length after strip=${body.length}`); - result = { uid: msg.uid, messageId: env?.messageId ?? '', @@ -282,17 +271,17 @@ export async function readMessage(uid: number, account: Account = 'yahoo', folde if (!result) throw new Error(`Message UID ${uid} not found`); - // Mark as seen AFTER the fetch loop fully completes — calling messageFlagsAdd - // inside the for-await loop deadlocks because the FETCH command is still active. console.log(`[imap] marking uid=${uid} as seen`); await client.messageFlagsAdd([uid], ['\\Seen'], { uid: true }); - console.log(`[imap] readMessage done uid=${uid}`); return result; }); } -export async function getProfile(account: Account = 'yahoo'): Promise<{ email: string; name: string; account: string }> { +export async function getProfile(ctx: EmailCtx = 'yahoo'): Promise<{ email: string; name: string; account: string }> { + if (typeof ctx === 'object') { + return { email: ctx.user, name: ctx.user.split('@')[0], account: 'custom' }; + } const emailMap: Record = { yahoo: process.env['YAHOO_EMAIL'] ?? '', fetcherpay: process.env['FETCHERPAY_EMAIL'] ?? '', @@ -302,12 +291,12 @@ export async function getProfile(account: Account = 'yahoo'): Promise<{ email: s founder: process.env['FOUNDER_EMAIL'] ?? '', gmail: process.env['GMAIL_EMAIL'] ?? '', }; - const email = emailMap[account] ?? ''; - return { email, name: email.split('@')[0], account }; + const email = emailMap[ctx] ?? ''; + return { email, name: email.split('@')[0], account: ctx }; } -export async function listFolders(account: Account = 'yahoo'): Promise { - return withClient(account, async (client) => { +export async function listFolders(ctx: EmailCtx = 'yahoo'): Promise { + return withClient(ctx, async (client) => { const mailboxes = await client.list(); return mailboxes.map((m) => m.path); }); diff --git a/src/index.ts b/src/index.ts index 898cd13..24b7886 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import 'dotenv/config'; +import crypto from 'crypto'; import express from 'express'; import cors from 'cors'; import cookieParser from 'cookie-parser'; @@ -11,7 +12,7 @@ import { ListResourcesRequestSchema, isInitializeRequest, } from '@modelcontextprotocol/sdk/types.js'; -import { tools, handleToolCall } from './tools.js'; +import { tools, handleToolCall, stripAccountParam } from './tools.js'; import { getManifest, getOpenApiSpec, getOpenApiSpecMail, getOpenApiSpecSocial } from './manifest.js'; import { routeWhatsAppWebhook, registerWhatsAppNumber, type RoutedWebhookEvent } from './multitenancy/webhook-router.js'; import { storeCredential, type Platform } from './multitenancy/credential-store.js'; @@ -22,12 +23,18 @@ import { createAuthCode, exchangeCodeForToken, validateAccessToken, + getTokenCustomer, getAuthorizeHtml, + isValidRedirectUri, + ensureOAuthAppRegistered, } from './oauth.js'; import { initDatabase, getPool } from './db.js'; import { hashPassword, verifyPassword, signJWT, verifyJWT, findCustomerByEmail, createCustomer, setResetToken, findCustomerByResetToken, clearResetToken, updatePassword } from './auth.js'; import { recordUsage, getMonthlyUsage, getUsageBreakdown, checkLimit } from './billing/usage.js'; import { getCustomerInvoices, getInvoiceByNumber, markInvoiceSent, markInvoicePaid, generateMonthlyInvoice } from './billing/invoices.js'; +import { getAllPlatformHealth } from './multitenancy/platform-health.js'; +import { deliverWebhook, isValidWebhookUrl } from './webhooks/delivery.js'; +import redis from './redis.js'; const app = express(); app.use(cookieParser()); @@ -320,9 +327,20 @@ async function requireAuth(req: express.Request, res: express.Response, next: ex } } - // 3. Check OAuth Bearer token + // 3. Check OAuth Bearer token — resolve to customer when token has customer_id binding const bearerToken = extractBearerToken(req); - if (bearerToken && await validateAccessToken(bearerToken)) return next(); + if (bearerToken) { + const tokenInfo = await getTokenCustomer(bearerToken); + if (tokenInfo) { + const customer = await resolveCustomerById(tokenInfo.customerId); + if (customer && customer.active) { + (req as express.Request & { customer?: Customer }).customer = customer; + return next(); + } + } + // Fall back to legacy tokens (no customer_id) — validate presence only + if (await validateAccessToken(bearerToken)) return next(); + } // 4. Check JWT session cookie (web app auth) const jwtCookie = req.cookies?.session; @@ -364,17 +382,19 @@ app.use((req, res, next) => { next(); }); -function createMcpServer() { +function createMcpServer(customer?: Customer) { const server = new Server( { name: 'hermes', version: '1.0.0' }, { capabilities: { tools: {}, resources: {} } } ); - server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools })); + const visibleTools = customer ? tools.map(stripAccountParam) : tools; + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: visibleTools })); server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] })); server.setRequestHandler(CallToolRequestSchema, async (request) => { return handleToolCall( request.params.name, - (request.params.arguments ?? {}) as Record + (request.params.arguments ?? {}) as Record, + customer ); }); return server; @@ -421,6 +441,26 @@ app.get('/oauth/authorize', async (req, res) => { return; } + if (!isValidRedirectUri(redirectUri, client.redirect_uris)) { + res.status(400).send('redirect_uri not registered for this client'); + return; + } + + // Require authenticated SquareMCP session to show the consent page + const jwtCookie = req.cookies?.session; + if (!jwtCookie) { + const returnTo = encodeURIComponent(req.originalUrl); + res.redirect(`/login?return_to=${returnTo}`); + return; + } + try { + verifyJWT(jwtCookie); + } catch { + const returnTo = encodeURIComponent(req.originalUrl); + res.redirect(`/login?return_to=${returnTo}`); + return; + } + res.setHeader('Content-Type', 'text/html'); res.send(getAuthorizeHtml({ client_id: clientId, @@ -452,6 +492,11 @@ app.post('/oauth/authorize', async (req, res) => { return; } + if (!isValidRedirectUri(redirectUri, client.redirect_uris)) { + res.status(400).send('redirect_uri not registered for this client'); + return; + } + if (action !== 'allow') { const url = new URL(redirectUri); url.searchParams.set('error', 'access_denied'); @@ -461,7 +506,19 @@ app.post('/oauth/authorize', async (req, res) => { return; } - const code = await createAuthCode(clientId, redirectUri, scope, codeChallenge, codeChallengeMethod); + // Bind the auth code to the authenticated customer if present + let customerId: string | undefined; + const jwtCookie = req.cookies?.session; + if (jwtCookie) { + try { + const payload = verifyJWT(jwtCookie); + customerId = payload.sub; + } catch { + // no binding — legacy flow + } + } + + const code = await createAuthCode(clientId, redirectUri, scope, codeChallenge, codeChallengeMethod, customerId); const url = new URL(redirectUri); url.searchParams.set('code', code.code); if (state) url.searchParams.set('state', state); @@ -502,6 +559,121 @@ app.post('/oauth/token', async (req, res) => { }); }); +// ── DCR browser flow ──────────────────────────────────────────────────────── + +// Kick off the "Connect MCP Client" browser flow — server redirects to consent page +// so the client_id never needs to be exposed in the frontend JS. +app.get('/oauth/connect-mcp', (req, res) => { + const clientId = process.env.OAUTH_CLIENT_ID; + if (!clientId) { + res.status(503).send('MCP OAuth app not configured (OAUTH_CLIENT_ID missing)'); + return; + } + const callbackUrl = `${SERVER_URL}/oauth/mcp-callback`; + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: callbackUrl, + response_type: 'code', + scope: 'mcp', + }); + res.redirect(`/oauth/authorize?${params}`); +}); + +// Callback — exchange code for token and render the config snippet page +app.get('/oauth/mcp-callback', async (req, res) => { + const code = req.query.code as string | undefined; + const error = req.query.error as string | undefined; + + if (error || !code) { + res.status(400).send(renderMcpCallbackHtml({ error: error || 'Missing authorization code' })); + return; + } + + const clientId = process.env.OAUTH_CLIENT_ID; + const clientSecret = process.env.OAUTH_CLIENT_SECRET; + if (!clientId || !clientSecret) { + res.status(503).send(renderMcpCallbackHtml({ error: 'Server misconfiguration — OAUTH_CLIENT_ID/SECRET missing' })); + return; + } + + const callbackUrl = `${SERVER_URL}/oauth/mcp-callback`; + const token = await exchangeCodeForToken(clientId, clientSecret, code, callbackUrl); + if (!token) { + res.status(400).send(renderMcpCallbackHtml({ error: 'Token exchange failed — code may be expired or already used' })); + return; + } + + res.setHeader('Content-Type', 'text/html'); + res.send(renderMcpCallbackHtml({ token: token.access_token, serverUrl: SERVER_URL })); +}); + +function renderMcpCallbackHtml(opts: { token?: string; serverUrl?: string; error?: string }): string { + if (opts.error) { + return `Connection failed + +

Connection failed

${opts.error}

`; + } + + const { token, serverUrl } = opts; + const claudeConfig = JSON.stringify({ + mcpServers: { 'hermes-mcp': { type: 'http', url: `${serverUrl}/mcp`, headers: { Authorization: `Bearer ${token}` } } } + }, null, 2); + const codexConfig = JSON.stringify({ + mcpServers: { 'hermes-mcp': { type: 'http', url: `${serverUrl}/mcp`, headers: { Authorization: `Bearer ${token}` } } } + }, null, 2); + + const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>'); + + return ` + + + +MCP Client Connected — SquareMCP + + + +
+

MCP Client Connected!

+

Copy your access token and the config for your MCP client below.

+ +

Your Access Token

+
${esc(token!)}
+

Store this securely — it won't be shown again.

+ +

Claude Desktop claude_desktop_config.json

+
${esc(claudeConfig)}
+ +

Codex CLI / opencode config

+
${esc(codexConfig)}
+
+ + +`; +} + // ── TikTok Login Kit + Content Posting auth flow ─────────────────────────── app.get('/auth/tiktok/start', async (req, res) => { if (!TIKTOK_CLIENT_KEY) { @@ -650,22 +822,25 @@ app.get('/auth/tiktok/callback', async (req, res) => { // ── Streamable HTTP transport (MCP 1.x standard) ──────────────────────────── const httpTransports = new Map(); +const sessionCustomers = new Map(); -async function createSession(): Promise { +async function createSession(customer?: Customer): Promise { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: (id) => { console.log(`[mcp] Session initialized: ${id}`); httpTransports.set(id, transport); + if (customer) sessionCustomers.set(id, customer); }, }); transport.onclose = () => { if (transport.sessionId) { console.log(`[mcp] Session closed: ${transport.sessionId}`); httpTransports.delete(transport.sessionId); + sessionCustomers.delete(transport.sessionId); } }; - const server = createMcpServer(); + const server = createMcpServer(customer); await server.connect(transport); return transport; } @@ -674,6 +849,7 @@ app.post('/mcp', requireAuth, async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; console.log(`[mcp] POST sessionId=${sessionId ?? 'none'}, isInit=${isInitializeRequest(req.body)}`); + const reqCustomer = (req as express.Request & { customer?: Customer }).customer; let transport: StreamableHTTPServerTransport; if (sessionId && httpTransports.has(sessionId)) { @@ -684,12 +860,12 @@ app.post('/mcp', requireAuth, async (req, res) => { console.warn(`[mcp] Stale session ${sessionId} re-initializing — pod may have restarted`); } console.log(`[mcp] Creating new session`); - transport = await createSession(); + transport = await createSession(reqCustomer); } else { // Stale session ID from a pod restart — transparently create a new session // and handle the request. Our tools are stateless so no context is lost. console.warn(`[mcp] Unknown session ${sessionId ?? '(none)'} — auto-recovering with new session`); - transport = await createSession(); + transport = await createSession(reqCustomer); } try { @@ -725,7 +901,8 @@ app.get('/sse', requireAuth, async (req, res) => { const transport = new SSEServerTransport('/messages', res); sseTransports.set(transport.sessionId, transport); res.on('close', () => sseTransports.delete(transport.sessionId)); - const server = createMcpServer(); + const sseCustomer = (req as express.Request & { customer?: Customer }).customer; + const server = createMcpServer(sseCustomer); await server.connect(transport); }); @@ -919,7 +1096,12 @@ app.get('/api/whatsapp/templates', requireAuth, async (req, res) => { // ── WhatsApp webhook (multi-tenant) ───────────────────────────── async function handleInboundWhatsAppMessage(event: RoutedWebhookEvent): Promise { console.log(`[webhook/whatsapp] inbound message from=${event.message.from} customer=${event.customerId} type=${event.message.type}`); - // Future: route to customer's agent or queue for processing + // Fire-and-forget — don't block the webhook acknowledgement + deliverWebhook(event.customerId, 'whatsapp', 'inbound_message', { + from: event.message.from, + text: (event.message as unknown as Record).text ?? null, + timestamp: event.message.timestamp, + }).catch((err) => console.error('[webhook/whatsapp] delivery error:', err)); } // WhatsApp webhook verification (GET) @@ -935,13 +1117,14 @@ app.get('/webhook/whatsapp', (req, res) => { } }); -// WhatsApp webhook delivery (POST) — multi-tenant routed -app.post('/webhook/whatsapp', express.json(), async (req, res) => { +// WhatsApp webhook delivery (POST) — raw body preserved for HMAC verification +app.post('/webhook/whatsapp', express.raw({ type: '*/*' }), async (req, res) => { // Always acknowledge immediately to prevent Meta retries (20s window) res.status(200).send('EVENT_RECEIVED'); try { - const events = await routeWhatsAppWebhook(req.body as Record); + const body = JSON.parse((req.body as Buffer).toString('utf8')) as Record; + const events = await routeWhatsAppWebhook(body); for (const event of events) { await handleInboundWhatsAppMessage(event); } @@ -1125,15 +1308,62 @@ app.get('/api/connections', meterMiddleware, async (req, res) => { const customer = (req as unknown as { customer: Customer }).customer; const platforms: Platform[] = ['email', 'whatsapp', 'linkedin', 'telegram', 'discord', 'instagram', 'twitter', 'tiktok', 'snapchat', 'facebook', 'obsidian']; - const status: Record = {}; - for (const platform of platforms) { - const cred = await customer.getCredential(platform); - status[platform] = cred !== null; - } + const results = await Promise.all(platforms.map((p) => customer.getCredential(p))); + const status: Record = Object.fromEntries( + platforms.map((p, i) => [p, results[i] !== null]) + ); res.json({ customerId: customer.id, connections: status }); }); +app.get('/api/health/platforms', meterMiddleware, async (req, res) => { + const customer = (req as unknown as { customer: Customer }).customer; + const health = await getAllPlatformHealth(customer.id); + res.json({ health }); +}); + +// ── Webhooks ──────────────────────────────────────────────────── + +app.get('/api/webhooks/config', meterMiddleware, async (req, res) => { + const customer = (req as unknown as { customer: Customer }).customer; + const [rows] = await getPool().query( + 'SELECT webhook_url FROM customers WHERE id = ?', + [customer.id] + ); + res.json({ webhookUrl: rows[0]?.webhook_url ?? null }); +}); + +app.post('/api/webhooks/config', meterMiddleware, async (req, res) => { + const customer = (req as unknown as { customer: Customer }).customer; + const webhookUrl = (req.body as Record).webhook_url as string | undefined; + + if (!webhookUrl) { + res.status(400).json({ error: 'webhook_url required' }); + return; + } + if (!isValidWebhookUrl(webhookUrl)) { + res.status(400).json({ error: 'Invalid webhook URL — must be https:// with a public hostname' }); + return; + } + + const secret = crypto.randomBytes(32).toString('hex'); + await getPool().query( + 'UPDATE customers SET webhook_url = ?, webhook_secret = ? WHERE id = ?', + [webhookUrl, secret, customer.id] + ); + // Secret returned only at creation/rotation; not retrievable afterward + res.json({ webhookUrl, webhookSecret: secret }); +}); + +app.delete('/api/webhooks/config', meterMiddleware, async (req, res) => { + const customer = (req as unknown as { customer: Customer }).customer; + await getPool().query( + 'UPDATE customers SET webhook_url = NULL, webhook_secret = NULL WHERE id = ?', + [customer.id] + ); + res.json({ deleted: true }); +}); + // ── Usage & Limits ────────────────────────────────────────────── app.get('/api/usage', meterMiddleware, async (req, res) => { @@ -1150,6 +1380,20 @@ app.get('/api/usage', meterMiddleware, async (req, res) => { }); }); +app.get('/api/usage/daily', meterMiddleware, async (req, res) => { + const customer = (req as unknown as { customer: Customer }).customer; + const [rows] = await getPool().query( + `SELECT DATE(created_at) as date, COUNT(*) as count + FROM usage_logs + WHERE customer_id = ? + AND created_at >= DATE_FORMAT(NOW(), '%Y-%m-01') + GROUP BY DATE(created_at) + ORDER BY date ASC`, + [customer.id] + ); + res.json({ daily: rows }); +}); + // ── Invoices ──────────────────────────────────────────────────── app.get('/api/invoices', meterMiddleware, async (req, res) => { @@ -1159,8 +1403,9 @@ app.get('/api/invoices', meterMiddleware, async (req, res) => { }); app.get('/api/invoices/:number', meterMiddleware, async (req, res) => { + const customer = (req as express.Request & { customer?: Customer }).customer; const invoice = await getInvoiceByNumber(req.params.number); - if (!invoice) { + if (!invoice || invoice.customer_id !== customer?.id) { res.status(404).json({ error: 'Invoice not found' }); return; } @@ -1297,6 +1542,13 @@ app.post('/api/admin/invoices/:number/pay', requireAdmin, async (req, res) => { res.json({ paid: true }); }); +app.get('/api/admin/webhooks/dlq/:customerId', requireAdmin, async (req, res) => { + const { customerId } = req.params; + const entries = await redis.lRange(`webhook:dlq:${customerId}`, 0, -1); + const events = entries.map((e) => JSON.parse(e) as unknown); + res.json({ customerId, events, count: events.length }); +}); + // ── LinkedIn REST endpoints ───────────────────────────────────── app.get('/api/linkedin/profile', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; @@ -1748,6 +2000,18 @@ app.get('/health', (_req, res) => { async function main() { await initDatabase(); + // Ensure the pre-registered SquareMCP OAuth app exists for the browser DCR flow + const oauthClientId = process.env.OAUTH_CLIENT_ID; + const oauthClientSecret = process.env.OAUTH_CLIENT_SECRET; + if (oauthClientId && oauthClientSecret) { + await ensureOAuthAppRegistered(oauthClientId, oauthClientSecret, [ + `${SERVER_URL}/oauth/mcp-callback`, + 'http://localhost:*', + 'claude-desktop://callback', + 'opencode://callback', + ]); + } + app.listen(PORT, () => { console.log(`Hermes MCP server running on port ${PORT}`); console.log(` Streamable HTTP: ${SERVER_URL}/mcp`); diff --git a/src/multitenancy/audit-log.ts b/src/multitenancy/audit-log.ts index 15ef31a..912c28c 100644 --- a/src/multitenancy/audit-log.ts +++ b/src/multitenancy/audit-log.ts @@ -1,7 +1,4 @@ -import { createClient } from 'redis'; - -const redis = createClient({ url: process.env.REDIS_URL }); -redis.connect().catch((err) => console.error('[audit-log] Redis connect error:', err)); +import redis from '../redis.js'; export interface AuditEntry { customerId: string; diff --git a/src/multitenancy/credential-store.test.ts b/src/multitenancy/credential-store.test.ts new file mode 100644 index 0000000..fc9ae00 --- /dev/null +++ b/src/multitenancy/credential-store.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockRedisGet, mockRedisSet, mockRedisDel, mockRedisKeys } = vi.hoisted(() => ({ + mockRedisGet: vi.fn(), + mockRedisSet: vi.fn(), + mockRedisDel: vi.fn(), + mockRedisKeys: vi.fn(), +})); + +vi.mock('../redis.js', () => ({ + default: { + get: mockRedisGet, + set: mockRedisSet, + del: mockRedisDel, + keys: mockRedisKeys, + }, +})); + +const mockTryRefreshToken = vi.hoisted(() => vi.fn()); +vi.mock('./token-refresh.js', () => ({ tryRefreshToken: mockTryRefreshToken })); + +// Use a real 32-byte key so AES-256-GCM doesn't throw +process.env.CREDENTIAL_ENCRYPTION_KEY = '0'.repeat(64); + +import { getCredential, storeCredential } from './credential-store.js'; + +function encryptCreds(creds: object): string { + // We can't call encrypt() directly (not exported), so we'll round-trip through storeCredential + // For tests, we'll use a helper approach: just test behavior using storeCredential to set up state. + return JSON.stringify(creds); // placeholder — see note below +} + +describe('getCredential', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRedisSet.mockResolvedValue('OK'); + }); + + it('returns null when no credential stored', async () => { + mockRedisGet.mockResolvedValue(null); + const result = await getCredential('cust1', 'linkedin'); + expect(result).toBeNull(); + }); + + it('returns credential when not expired', async () => { + // Store then retrieve using real encryption + const creds = { accessToken: 'tok', expiresAt: Date.now() + 3_600_000 }; + await storeCredential('cust1', 'linkedin', creds); + const stored = mockRedisSet.mock.calls[0][1] as string; + mockRedisGet.mockResolvedValue(stored); + + const result = await getCredential('cust1', 'linkedin'); + expect(result).toMatchObject({ accessToken: 'tok' }); + expect(mockTryRefreshToken).not.toHaveBeenCalled(); + }); + + it('attempts refresh when token is within 60s of expiry', async () => { + const creds = { accessToken: 'old', refreshToken: 'ref', expiresAt: Date.now() + 30_000 }; + await storeCredential('cust1', 'linkedin', creds); + const stored = mockRedisSet.mock.calls[0][1] as string; + mockRedisGet.mockResolvedValue(stored); + mockTryRefreshToken.mockResolvedValue({ accessToken: 'new', expiresAt: Date.now() + 3_600_000 }); + + const result = await getCredential<{ accessToken: string }>('cust1', 'linkedin'); + expect(mockTryRefreshToken).toHaveBeenCalledWith('cust1', 'linkedin', expect.objectContaining({ accessToken: 'old' })); + expect(result?.accessToken).toBe('new'); + }); + + it('returns null when token expired and no refresh token', async () => { + const creds = { accessToken: 'old', expiresAt: Date.now() - 1000 }; + await storeCredential('cust1', 'linkedin', creds); + const stored = mockRedisSet.mock.calls[0][1] as string; + mockRedisGet.mockResolvedValue(stored); + + const result = await getCredential('cust1', 'linkedin'); + expect(result).toBeNull(); + expect(mockTryRefreshToken).not.toHaveBeenCalled(); + }); + + it('returns null when refresh fails', async () => { + const creds = { accessToken: 'old', refreshToken: 'ref', expiresAt: Date.now() - 1000 }; + await storeCredential('cust1', 'linkedin', creds); + const stored = mockRedisSet.mock.calls[0][1] as string; + mockRedisGet.mockResolvedValue(stored); + mockTryRefreshToken.mockResolvedValue(null); + + const result = await getCredential('cust1', 'linkedin'); + expect(result).toBeNull(); + }); + + it('returns non-OAuth credentials without expiry check', async () => { + const creds = { host: 'imap.gmail.com', port: 993, user: 'u', password: 'p' }; + await storeCredential('cust1', 'email', creds); + const stored = mockRedisSet.mock.calls[0][1] as string; + mockRedisGet.mockResolvedValue(stored); + + const result = await getCredential('cust1', 'email'); + expect(result).toMatchObject({ host: 'imap.gmail.com' }); + expect(mockTryRefreshToken).not.toHaveBeenCalled(); + }); +}); diff --git a/src/multitenancy/credential-store.ts b/src/multitenancy/credential-store.ts index 6e4224f..fceec69 100644 --- a/src/multitenancy/credential-store.ts +++ b/src/multitenancy/credential-store.ts @@ -1,8 +1,5 @@ import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; -import { createClient } from 'redis'; - -const redis = createClient({ url: process.env.REDIS_URL }); -redis.connect().catch((err) => console.error('[credential-store] Redis connect error:', err)); +import redis from '../redis.js'; const ENCRYPTION_KEY = Buffer.from(process.env.CREDENTIAL_ENCRYPTION_KEY ?? '0'.repeat(64), 'hex'); // CREDENTIAL_ENCRYPTION_KEY must be a 64-char hex string (32 bytes) @@ -70,7 +67,21 @@ export async function getCredential( const key = `creds:${customerId}:${platform}`; const encrypted = await redis.get(key); if (!encrypted) return null; - return JSON.parse(decrypt(encrypted)) as T; + const creds = JSON.parse(decrypt(encrypted)) as T; + + const oauth = creds as OAuthCredentials; + if (typeof oauth.accessToken === 'string' && typeof oauth.expiresAt === 'number') { + if (oauth.expiresAt < Date.now() + 60_000) { + if (oauth.refreshToken) { + const { tryRefreshToken } = await import('./token-refresh.js'); + const refreshed = await tryRefreshToken(customerId, platform, oauth); + if (refreshed) return refreshed as T; + } + return null; + } + } + + return creds; } export async function revokeCredential(customerId: string, platform: Platform): Promise { diff --git a/src/multitenancy/platform-health.test.ts b/src/multitenancy/platform-health.test.ts new file mode 100644 index 0000000..0ec1806 --- /dev/null +++ b/src/multitenancy/platform-health.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockRedisGet, mockRedisSetEx } = vi.hoisted(() => ({ + mockRedisGet: vi.fn(), + mockRedisSetEx: vi.fn(), +})); + +vi.mock('../redis.js', () => ({ + default: { + get: mockRedisGet, + setEx: mockRedisSetEx, + }, +})); + +const mockGetCredential = vi.hoisted(() => vi.fn()); +vi.mock('./credential-store.js', () => ({ getCredential: mockGetCredential })); + +global.fetch = vi.fn(); + +import { getAllPlatformHealth } from './platform-health.js'; + +describe('getAllPlatformHealth', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRedisGet.mockResolvedValue(null); + mockRedisSetEx.mockResolvedValue('OK'); + }); + + it('returns cached status without hitting API', async () => { + mockRedisGet.mockImplementation((key: string) => + key.includes('linkedin') ? Promise.resolve('healthy') : Promise.resolve(null) + ); + mockGetCredential.mockResolvedValue(null); + + const results = await getAllPlatformHealth('cust1'); + const linkedin = results.find(r => r.platform === 'linkedin'); + expect(linkedin?.status).toBe('healthy'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('returns disconnected when no credential stored', async () => { + mockGetCredential.mockResolvedValue(null); + // Second redis.get (raw key check) also returns null + mockRedisGet.mockResolvedValue(null); + + const results = await getAllPlatformHealth('cust1'); + results.forEach(r => { + expect(r.status).toBe('disconnected'); + }); + }); + + it('returns healthy when OAuth probe succeeds', async () => { + mockGetCredential.mockImplementation((id: string, platform: string) => + platform === 'linkedin' ? Promise.resolve({ accessToken: 'tok' }) : Promise.resolve(null) + ); + (fetch as ReturnType).mockResolvedValue({ ok: true }); + mockRedisGet.mockImplementation((key: string) => + key.startsWith('creds:') ? Promise.resolve(null) : Promise.resolve(null) + ); + + const results = await getAllPlatformHealth('cust1'); + const linkedin = results.find(r => r.platform === 'linkedin'); + expect(linkedin?.status).toBe('healthy'); + }); + + it('returns expired when OAuth probe returns non-ok', async () => { + mockGetCredential.mockImplementation((_id: string, platform: string) => + platform === 'twitter' ? Promise.resolve({ accessToken: 'tok' }) : Promise.resolve(null) + ); + (fetch as ReturnType).mockResolvedValue({ ok: false, status: 401 }); + mockRedisGet.mockResolvedValue(null); + + const results = await getAllPlatformHealth('cust1'); + const twitter = results.find(r => r.platform === 'twitter'); + expect(twitter?.status).toBe('expired'); + }); + + it('returns unknown when fetch throws', async () => { + mockGetCredential.mockImplementation((_id: string, platform: string) => + platform === 'tiktok' ? Promise.resolve({ accessToken: 'tok' }) : Promise.resolve(null) + ); + (fetch as ReturnType).mockRejectedValue(new Error('network error')); + mockRedisGet.mockResolvedValue(null); + + const results = await getAllPlatformHealth('cust1'); + const tiktok = results.find(r => r.platform === 'tiktok'); + expect(tiktok?.status).toBe('unknown'); + }); + + it('caches the result in Redis', async () => { + mockGetCredential.mockResolvedValue(null); + mockRedisGet.mockResolvedValue(null); + + await getAllPlatformHealth('cust1'); + expect(mockRedisSetEx).toHaveBeenCalledWith( + expect.stringContaining('health:cust1:'), + 600, + expect.any(String) + ); + }); + + it('returns healthy for non-OAuth platforms when credential exists', async () => { + mockGetCredential.mockImplementation((_id: string, platform: string) => + platform === 'email' ? Promise.resolve({ host: 'smtp.example.com', port: 587, user: 'u', password: 'p' }) : Promise.resolve(null) + ); + mockRedisGet.mockResolvedValue(null); + + const results = await getAllPlatformHealth('cust1'); + const email = results.find(r => r.platform === 'email'); + expect(email?.status).toBe('healthy'); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/multitenancy/platform-health.ts b/src/multitenancy/platform-health.ts new file mode 100644 index 0000000..9a54f69 --- /dev/null +++ b/src/multitenancy/platform-health.ts @@ -0,0 +1,72 @@ +import redis from '../redis.js'; +import { getCredential, type Platform } from './credential-store.js'; + +const HEALTH_TTL = 600; // 10 minutes + +type HealthStatus = 'healthy' | 'expired' | 'disconnected' | 'unknown'; + +interface PlatformHealth { + platform: Platform; + status: HealthStatus; +} + +const OAUTH_PLATFORMS: Platform[] = ['linkedin', 'twitter', 'tiktok', 'instagram', 'facebook', 'snapchat']; +const ALL_PLATFORMS: Platform[] = ['email', 'whatsapp', 'linkedin', 'telegram', 'discord', 'instagram', 'twitter', 'tiktok', 'snapchat', 'facebook', 'obsidian']; + +async function checkPlatformHealth(customerId: string, platform: Platform): Promise { + const cacheKey = `health:${customerId}:${platform}`; + const cached = await redis.get(cacheKey); + if (cached) return cached as HealthStatus; + + const cred = await getCredential(customerId, platform); + let status: HealthStatus; + + if (!cred) { + // getCredential returns null for both "not stored" and "expired with no refresh" + // Check if there was a stored (but expired) credential by looking at the raw key + const rawKey = `creds:${customerId}:${platform}`; + const raw = await redis.get(rawKey); + status = raw ? 'expired' : 'disconnected'; + } else if (OAUTH_PLATFORMS.includes(platform)) { + // Credential exists and is not expired — probe the API + status = await probeOAuthPlatform(platform, cred as { accessToken: string }); + } else { + status = 'healthy'; + } + + await redis.setEx(cacheKey, HEALTH_TTL, status); + return status; +} + +async function probeOAuthPlatform(platform: Platform, cred: { accessToken: string }): Promise { + const probeUrls: Partial> = { + linkedin: 'https://api.linkedin.com/v2/userinfo', + twitter: 'https://api.twitter.com/2/users/me', + tiktok: 'https://open.tiktokapis.com/v2/user/info/?fields=open_id', + instagram: 'https://graph.instagram.com/me?fields=id', + facebook: 'https://graph.facebook.com/me?fields=id', + snapchat: 'https://adsapi.snapchat.com/v1/me', + }; + + const url = probeUrls[platform]; + if (!url) return 'unknown'; + + try { + const res = await fetch(url, { + headers: { Authorization: `Bearer ${cred.accessToken}` }, + signal: AbortSignal.timeout(8000), + }); + return res.ok ? 'healthy' : 'expired'; + } catch { + return 'unknown'; + } +} + +export async function getAllPlatformHealth(customerId: string): Promise { + return Promise.all( + ALL_PLATFORMS.map(async (platform) => ({ + platform, + status: await checkPlatformHealth(customerId, platform), + })) + ); +} diff --git a/src/multitenancy/token-refresh.ts b/src/multitenancy/token-refresh.ts new file mode 100644 index 0000000..0fce604 --- /dev/null +++ b/src/multitenancy/token-refresh.ts @@ -0,0 +1,102 @@ +import { storeCredential, type OAuthCredentials, type Platform } from './credential-store.js'; + +interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in?: number; +} + +async function postRefresh(url: string, body: URLSearchParams): Promise { + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + signal: AbortSignal.timeout(15000), + }); + if (!res.ok) { + console.warn(`[token-refresh] HTTP ${res.status} from ${url}`); + return null; + } + return await res.json() as TokenResponse; + } catch (err) { + console.error(`[token-refresh] request failed:`, err); + return null; + } +} + +export async function tryRefreshToken( + customerId: string, + platform: Platform, + creds: OAuthCredentials +): Promise { + let data: TokenResponse | null = null; + + if (platform === 'linkedin') { + data = await postRefresh('https://www.linkedin.com/oauth/v2/accessToken', new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: creds.refreshToken!, + client_id: process.env.LINKEDIN_CLIENT_ID ?? '', + client_secret: process.env.LINKEDIN_CLIENT_SECRET ?? '', + })); + + } else if (platform === 'twitter') { + const credentials = Buffer.from( + `${process.env.TWITTER_CLIENT_ID ?? ''}:${process.env.TWITTER_CLIENT_SECRET ?? ''}` + ).toString('base64'); + try { + const res = await fetch('https://api.twitter.com/2/oauth2/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': `Basic ${credentials}`, + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: creds.refreshToken!, + }).toString(), + signal: AbortSignal.timeout(15000), + }); + if (res.ok) data = await res.json() as TokenResponse; + else console.warn(`[token-refresh] twitter HTTP ${res.status}`); + } catch (err) { + console.error(`[token-refresh] twitter error:`, err); + } + + } else if (platform === 'tiktok') { + data = await postRefresh('https://open.tiktokapis.com/v2/oauth/token/', new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: creds.refreshToken!, + client_key: process.env.TIKTOK_CLIENT_KEY ?? '', + client_secret: process.env.TIKTOK_CLIENT_SECRET ?? '', + })); + + } else if (platform === 'instagram' || platform === 'facebook') { + // Facebook long-lived token exchange uses the current access token, not refresh token + try { + const url = new URL('https://graph.facebook.com/oauth/access_token'); + url.searchParams.set('grant_type', 'fb_exchange_token'); + url.searchParams.set('client_id', process.env.FACEBOOK_APP_ID ?? ''); + url.searchParams.set('client_secret', process.env.FACEBOOK_APP_SECRET ?? ''); + url.searchParams.set('fb_exchange_token', creds.accessToken); + const res = await fetch(url.toString(), { signal: AbortSignal.timeout(15000) }); + if (res.ok) data = await res.json() as TokenResponse; + else console.warn(`[token-refresh] ${platform} HTTP ${res.status}`); + } catch (err) { + console.error(`[token-refresh] ${platform} error:`, err); + } + } + + if (!data?.access_token) return null; + + const refreshed: OAuthCredentials = { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? creds.refreshToken, + expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined, + scope: creds.scope, + }; + + await storeCredential(customerId, platform, refreshed); + console.log(`[token-refresh] ${platform} refreshed for customer ${customerId}`); + return refreshed; +} diff --git a/src/multitenancy/webhook-router.ts b/src/multitenancy/webhook-router.ts index a716849..da76142 100644 --- a/src/multitenancy/webhook-router.ts +++ b/src/multitenancy/webhook-router.ts @@ -1,9 +1,6 @@ -import { createClient } from 'redis'; +import redis from '../redis.js'; import { getCredential, WhatsAppCredentials } from './credential-store.js'; -const redis = createClient({ url: process.env.REDIS_URL }); -redis.connect().catch((err) => console.error('[webhook-router] Redis connect error:', err)); - // Call this at customer onboarding when they connect their WhatsApp Business number export async function registerWhatsAppNumber( customerId: string, diff --git a/src/oauth-dcr.test.ts b/src/oauth-dcr.test.ts new file mode 100644 index 0000000..1ca199f --- /dev/null +++ b/src/oauth-dcr.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ResultSetHeader } from 'mysql2'; + +const { mockExecute } = vi.hoisted(() => ({ mockExecute: vi.fn() })); + +vi.mock('./db.js', () => ({ + getPool: vi.fn(() => ({ execute: mockExecute })), + isPoolReady: vi.fn(() => true), +})); + +import { ensureOAuthAppRegistered, isValidRedirectUri } from './oauth.js'; + +function affected(n: number): [ResultSetHeader, unknown[]] { + return [{ affectedRows: n } as ResultSetHeader, []]; +} + +describe('ensureOAuthAppRegistered', () => { + beforeEach(() => vi.clearAllMocks()); + + it('executes an INSERT ... ON DUPLICATE KEY UPDATE', async () => { + mockExecute.mockResolvedValue(affected(1)); + + await ensureOAuthAppRegistered('client-1', 'secret-1', [ + 'https://app.example.com/oauth/mcp-callback', + 'http://localhost:*', + 'claude-desktop://callback', + ]); + + const [sql, params] = mockExecute.mock.calls[0] as [string, unknown[]]; + expect(sql).toContain('INSERT INTO oauth_clients'); + expect(sql).toContain('ON DUPLICATE KEY UPDATE'); + expect(params[0]).toBe('client-1'); + expect(params[1]).toBe('secret-1'); + const redirectUris = JSON.parse(params[3] as string); + expect(redirectUris).toContain('http://localhost:*'); + expect(redirectUris).toContain('claude-desktop://callback'); + }); + + it('tolerates DB errors gracefully (does not throw)', async () => { + // Should propagate — not silently swallow — so callers can log + mockExecute.mockRejectedValue(new Error('DB gone')); + await expect( + ensureOAuthAppRegistered('c', 's', []) + ).rejects.toThrow('DB gone'); + }); +}); + +describe('isValidRedirectUri — DCR client redirect URIs', () => { + const registered = [ + 'https://hermes.squaremcp.com/oauth/mcp-callback', + 'http://localhost:*', + 'claude-desktop://callback', + 'opencode://callback', + ]; + + it('allows the SquareMCP mcp-callback URI exactly', () => { + expect(isValidRedirectUri('https://hermes.squaremcp.com/oauth/mcp-callback', registered)).toBe(true); + }); + + it('allows claude-desktop://callback exactly', () => { + expect(isValidRedirectUri('claude-desktop://callback', registered)).toBe(true); + }); + + it('allows opencode://callback exactly', () => { + expect(isValidRedirectUri('opencode://callback', registered)).toBe(true); + }); + + it('allows any localhost port via http://localhost:* wildcard', () => { + expect(isValidRedirectUri('http://localhost:3000', registered)).toBe(true); + expect(isValidRedirectUri('http://localhost:52481/callback', registered)).toBe(true); + }); + + it('rejects unregistered URIs', () => { + expect(isValidRedirectUri('https://evil.com', registered)).toBe(false); + expect(isValidRedirectUri('https://hermes.squaremcp.com/other', registered)).toBe(false); + }); + + it('rejects https://localhost (SSL loopback) — not in registered list', () => { + expect(isValidRedirectUri('https://localhost:3000', registered)).toBe(false); + }); +}); diff --git a/src/oauth.test.ts b/src/oauth.test.ts new file mode 100644 index 0000000..b0a4ad8 --- /dev/null +++ b/src/oauth.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ResultSetHeader } from 'mysql2'; + +// ── DB mock ──────────────────────────────────────────────────────────────── +const { mockExecute } = vi.hoisted(() => ({ mockExecute: vi.fn() })); + +vi.mock('./db.js', () => ({ + getPool: vi.fn(() => ({ execute: mockExecute })), + isPoolReady: vi.fn(() => true), +})); + +import { + createAuthCode, + exchangeCodeForToken, + validateAccessToken, + getTokenCustomer, + isValidRedirectUri, +} from './oauth.js'; + +// Helper: build a minimal ResultSetHeader-shaped object +function affected(n: number): [ResultSetHeader, unknown[]] { + return [{ affectedRows: n } as ResultSetHeader, []]; +} + +// Helper: build a row-result +function rows(data: object[]): [object[], unknown[]] { + return [data, []]; +} + +describe('isValidRedirectUri', () => { + it('exact match is valid', () => { + expect(isValidRedirectUri('https://app.example.com/callback', ['https://app.example.com/callback'])).toBe(true); + }); + + it('http://localhost:* wildcard matches any localhost port', () => { + expect(isValidRedirectUri('http://localhost:3000', ['http://localhost:*'])).toBe(true); + expect(isValidRedirectUri('http://localhost:9876/callback', ['http://localhost:*'])).toBe(true); + }); + + it('http://localhost:* does not match non-localhost', () => { + expect(isValidRedirectUri('http://evil.com', ['http://localhost:*'])).toBe(false); + expect(isValidRedirectUri('https://localhost:3000', ['http://localhost:*'])).toBe(false); + }); + + it('unregistered URI is rejected', () => { + expect(isValidRedirectUri('https://attacker.com', ['https://app.example.com/callback'])).toBe(false); + }); + + it('other wildcards are not supported', () => { + expect(isValidRedirectUri('https://any.example.com', ['https://*.example.com'])).toBe(false); + }); + + it('empty registered list rejects everything', () => { + expect(isValidRedirectUri('https://app.example.com/callback', [])).toBe(false); + }); +}); + +describe('createAuthCode', () => { + beforeEach(() => vi.clearAllMocks()); + + it('inserts with customer_id when provided', async () => { + mockExecute + // getClient SELECT (called by nothing here; we call createAuthCode directly) + .mockResolvedValueOnce([[], []]); // INSERT returns ResultSetHeader but we ignore it + + // The first execute is the INSERT + mockExecute.mockResolvedValue([{ insertId: 0, affectedRows: 1 }, []]); + + const code = await createAuthCode('client1', 'http://localhost:3000', 'read', undefined, undefined, 'cust-42'); + + expect(code.customer_id).toBe('cust-42'); + const [sql, params] = mockExecute.mock.calls[0] as [string, unknown[]]; + expect(sql).toContain('customer_id'); + expect(params).toContain('cust-42'); + }); + + it('inserts NULL customer_id when not provided', async () => { + mockExecute.mockResolvedValue([{ insertId: 0, affectedRows: 1 }, []]); + + const code = await createAuthCode('client1', 'http://localhost:3000'); + expect(code.customer_id).toBeUndefined(); + + const [, params] = mockExecute.mock.calls[0] as [string, unknown[]]; + // Last param should be null + expect(params[params.length - 1]).toBeNull(); + }); +}); + +describe('exchangeCodeForToken — replay protection', () => { + beforeEach(() => vi.clearAllMocks()); + + const fakeClient = { + client_id: 'c1', + client_secret: 'secret', + client_name: 'Test', + redirect_uris: ['http://localhost:3000'], + created_at: Date.now(), + }; + const fakeAuthCode = { + code: 'abc', + client_id: 'c1', + redirect_uri: 'http://localhost:3000', + scope: 'read', + code_challenge: null, + code_challenge_method: null, + customer_id: 'cust-1', + expires_at: new Date(Date.now() + 60_000), + used: true, + }; + + it('returns null when UPDATE affectedRows = 0 (already used)', async () => { + // getClient SELECT + mockExecute.mockResolvedValueOnce(rows([fakeClient])); + // UPDATE oauth_clients last_used + mockExecute.mockResolvedValueOnce(affected(1)); + // Atomic UPDATE auth code — 0 rows (already used) + mockExecute.mockResolvedValueOnce(affected(0)); + + const result = await exchangeCodeForToken('c1', 'secret', 'abc', 'http://localhost:3000'); + expect(result).toBeNull(); + }); + + it('issues token and threads customer_id on success', async () => { + // getClient SELECT + mockExecute.mockResolvedValueOnce(rows([fakeClient])); + // getClient UPDATE last_used + mockExecute.mockResolvedValueOnce(affected(1)); + // Atomic UPDATE auth code — 1 row consumed + mockExecute.mockResolvedValueOnce(affected(1)); + // SELECT auth code data + mockExecute.mockResolvedValueOnce(rows([fakeAuthCode])); + // INSERT token + mockExecute.mockResolvedValueOnce(affected(1)); + + const token = await exchangeCodeForToken('c1', 'secret', 'abc', 'http://localhost:3000'); + expect(token).not.toBeNull(); + expect(token!.access_token).toBeTruthy(); + + // Verify INSERT includes customer_id = 'cust-1' + const insertCall = mockExecute.mock.calls.find((c) => (c[0] as string).includes('INSERT INTO oauth_tokens')); + expect(insertCall).toBeDefined(); + const insertParams = insertCall![1] as unknown[]; + expect(insertParams).toContain('cust-1'); + }); + + it('returns null when client/redirect mismatch', async () => { + mockExecute.mockResolvedValueOnce(rows([fakeClient])); + mockExecute.mockResolvedValueOnce(affected(1)); + mockExecute.mockResolvedValueOnce(affected(1)); + // SELECT returns code with different client_id + mockExecute.mockResolvedValueOnce(rows([{ ...fakeAuthCode, client_id: 'other' }])); + + const result = await exchangeCodeForToken('c1', 'secret', 'abc', 'http://localhost:3000'); + expect(result).toBeNull(); + }); +}); + +describe('getTokenCustomer', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns customerId when token has one', async () => { + mockExecute.mockResolvedValue(rows([{ customer_id: 'cust-99' }])); + const result = await getTokenCustomer('tok-abc'); + expect(result).toEqual({ customerId: 'cust-99' }); + }); + + it('returns null when token not found', async () => { + mockExecute.mockResolvedValue(rows([])); + const result = await getTokenCustomer('bad-token'); + expect(result).toBeNull(); + }); + + it('returns null when token has no customer_id', async () => { + mockExecute.mockResolvedValue(rows([{ customer_id: null }])); + const result = await getTokenCustomer('tok-legacy'); + expect(result).toBeNull(); + }); +}); + +describe('validateAccessToken', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns true for valid token', async () => { + mockExecute.mockResolvedValue(rows([{ token: 'tok' }])); + expect(await validateAccessToken('tok')).toBe(true); + }); + + it('returns false for expired/missing token', async () => { + mockExecute.mockResolvedValue(rows([])); + expect(await validateAccessToken('bad')).toBe(false); + }); +}); diff --git a/src/oauth.ts b/src/oauth.ts index 6d4c24b..50dd53a 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -21,6 +21,7 @@ interface AuthCode { code_challenge?: string; code_challenge_method?: string; expires_at: number; + customer_id?: string; } interface Token { @@ -72,6 +73,23 @@ function verifyPkce(codeVerifier: string, storedChallenge: string, method?: stri return false; } +export async function ensureOAuthAppRegistered( + clientId: string, + clientSecret: string, + redirectUris: string[] +): Promise { + const pool = getPool(); + await pool.execute( + `INSERT INTO oauth_clients (client_id, client_secret, client_name, redirect_urls, grant_types) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + client_secret = VALUES(client_secret), + redirect_urls = VALUES(redirect_urls)`, + [clientId, clientSecret, 'SquareMCP App', JSON.stringify(redirectUris), JSON.stringify(['authorization_code'])] + ); + console.log(`[oauth] Pre-registered app ${clientId} ensured`); +} + export async function registerClient(body: { client_name?: string; redirect_uris?: string[]; @@ -139,7 +157,8 @@ export async function createAuthCode( redirectUri: string, scope?: string, codeChallenge?: string, - codeChallengeMethod?: string + codeChallengeMethod?: string, + customerId?: string ): Promise { const code: AuthCode = { code: generateAuthCode(), @@ -149,18 +168,27 @@ export async function createAuthCode( code_challenge: codeChallenge, code_challenge_method: codeChallengeMethod, expires_at: Date.now() + AUTH_CODE_EXPIRY_MS, + customer_id: customerId, }; const pool = getPool(); await pool.execute( - 'INSERT INTO oauth_auth_codes (code, client_id, redirect_uri, scope, code_challenge, code_challenge_method, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)', - [code.code, clientId, redirectUri, scope || null, codeChallenge || null, codeChallengeMethod || null, new Date(code.expires_at)] + 'INSERT INTO oauth_auth_codes (code, client_id, redirect_uri, scope, code_challenge, code_challenge_method, expires_at, customer_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [code.code, clientId, redirectUri, scope || null, codeChallenge || null, codeChallengeMethod || null, new Date(code.expires_at), customerId || null] ); console.log(`[oauth] Created auth code ${code.code.slice(0, 8)}... for client ${clientId}`); return code; } +export function isValidRedirectUri(uri: string, registeredUris: string[]): boolean { + for (const registered of registeredUris) { + if (registered === uri) return true; + if (registered === 'http://localhost:*' && /^http:\/\/localhost:\d+(\/|$)/.test(uri)) return true; + } + return false; +} + export async function exchangeCodeForToken( clientId: string, clientSecret: string | undefined, @@ -192,19 +220,27 @@ export async function exchangeCodeForToken( } const pool = getPool(); - const db = await pool.getConnection(); try { - const [rows] = await db.execute( - 'SELECT * FROM oauth_auth_codes WHERE code = ? AND used = FALSE AND expires_at > NOW()', + // Atomic consume: only one concurrent request can win this UPDATE + const [updateResult] = await pool.execute( + 'UPDATE oauth_auth_codes SET used = TRUE WHERE code = ? AND used = FALSE AND expires_at > NOW()', [code] ); - - if (!Array.isArray(rows) || rows.length === 0) { - console.log('[oauth] Auth code not found or expired'); + if (updateResult.affectedRows === 0) { + console.log('[oauth] Auth code not found, expired, or already used'); return null; } + // Fetch the row now that it is consumed + const [rows] = await pool.execute( + 'SELECT * FROM oauth_auth_codes WHERE code = ?', + [code] + ); + if (!Array.isArray(rows) || rows.length === 0) { + return null; + } const authCode = rows[0]; + if (authCode.client_id !== clientId || authCode.redirect_uri !== redirectUri) { console.log('[oauth] Auth code client/redirect mismatch'); return null; @@ -215,16 +251,12 @@ export async function exchangeCodeForToken( console.log('[oauth] Missing code_verifier for PKCE exchange'); return null; } - if (!verifyPkce(codeVerifier, authCode.code_challenge, authCode.code_challenge_method || undefined)) { console.log('[oauth] PKCE verification failed'); return null; } } - // Mark auth code as used - await db.execute('UPDATE oauth_auth_codes SET used = TRUE WHERE code = ?', [code]); - const token: Token = { access_token: generateAccessToken(), token_type: 'Bearer', @@ -233,9 +265,9 @@ export async function exchangeCodeForToken( expires_at: Date.now() + TOKEN_EXPIRY_MS, }; - await db.execute( - 'INSERT INTO oauth_tokens (token, client_id, token_type, expires_at) VALUES (?, ?, ?, ?)', - [token.access_token, clientId, 'access', new Date(token.expires_at)] + await pool.execute( + 'INSERT INTO oauth_tokens (token, client_id, token_type, expires_at, customer_id) VALUES (?, ?, ?, ?, ?)', + [token.access_token, clientId, 'access', new Date(token.expires_at), authCode.customer_id || null] ); console.log(`[oauth] Issued token ${token.access_token.slice(0, 8)}... for client ${clientId}`); @@ -243,8 +275,6 @@ export async function exchangeCodeForToken( } catch (err) { console.error('[oauth] exchangeCodeForToken error:', err); return null; - } finally { - db.release(); } } @@ -252,21 +282,31 @@ export async function validateAccessToken(tokenValue: string): Promise try { const pool = getPool(); const [rows] = await pool.execute( - 'SELECT * FROM oauth_tokens WHERE token = ? AND expires_at > NOW()', + 'SELECT token FROM oauth_tokens WHERE token = ? AND expires_at > NOW()', [tokenValue] ); - - if (!Array.isArray(rows) || rows.length === 0) { - return false; - } - - return true; + return Array.isArray(rows) && rows.length > 0; } catch (err) { console.error('[oauth] validateAccessToken error:', err); return false; } } +export async function getTokenCustomer(tokenValue: string): Promise<{ customerId: string } | null> { + try { + const pool = getPool(); + const [rows] = await pool.execute( + 'SELECT customer_id FROM oauth_tokens WHERE token = ? AND expires_at > NOW()', + [tokenValue] + ); + if (!Array.isArray(rows) || rows.length === 0 || !rows[0].customer_id) return null; + return { customerId: rows[0].customer_id as string }; + } catch (err) { + console.error('[oauth] getTokenCustomer error:', err); + return null; + } +} + export function getAuthorizeHtml(params: { client_id: string; redirect_uri: string; diff --git a/src/redis.ts b/src/redis.ts new file mode 100644 index 0000000..71e8bed --- /dev/null +++ b/src/redis.ts @@ -0,0 +1,6 @@ +import { createClient } from 'redis'; + +const redis = createClient({ url: process.env.REDIS_URL }); +redis.connect().catch((err) => console.error('[redis] connect error:', err)); + +export default redis; diff --git a/src/smtp.ts b/src/smtp.ts index 5068d97..1e15567 100644 --- a/src/smtp.ts +++ b/src/smtp.ts @@ -1,5 +1,6 @@ import nodemailer from 'nodemailer'; -import type { Account } from './imap.js'; +import type { Account, EmailCtx } from './imap.js'; +import type { EmailCredentials } from './multitenancy/credential-store.js'; const FETCHERPAY_SMTP_HOST = process.env['FETCHERPAY_SMTP_HOST'] ?? 'mail.fetcherpay.com'; const FETCHERPAY_SMTP_PORT = parseInt(process.env['FETCHERPAY_SMTP_PORT'] ?? '30587'); @@ -8,13 +9,13 @@ function fetcherpaySmtpTransport(user: string, pass: string) { return nodemailer.createTransport({ host: FETCHERPAY_SMTP_HOST, port: FETCHERPAY_SMTP_PORT, - secure: false, // STARTTLS + secure: false, auth: { user, pass }, tls: { rejectUnauthorized: false }, }); } -function getSmtpTransport(account: Account = 'yahoo') { +function getEnvSmtpTransport(account: Account = 'yahoo') { switch (account) { case 'fetcherpay': return fetcherpaySmtpTransport(process.env['FETCHERPAY_EMAIL']!, process.env['FETCHERPAY_PASSWORD']!); @@ -31,25 +32,33 @@ function getSmtpTransport(account: Account = 'yahoo') { host: 'smtp.gmail.com', port: 587, secure: false, - auth: { - user: process.env['GMAIL_EMAIL']!, - pass: process.env['GMAIL_APP_PASSWORD']!, - }, + auth: { user: process.env['GMAIL_EMAIL']!, pass: process.env['GMAIL_APP_PASSWORD']! }, }); default: return nodemailer.createTransport({ host: 'smtp.mail.yahoo.com', port: 587, secure: false, - auth: { - user: process.env['YAHOO_EMAIL']!, - pass: process.env['YAHOO_APP_PASSWORD']!, - }, + auth: { user: process.env['YAHOO_EMAIL']!, pass: process.env['YAHOO_APP_PASSWORD']! }, }); } } -function getSenderEmail(account: Account = 'yahoo'): string { +function resolveSmtpTransport(ctx: EmailCtx) { + if (typeof ctx === 'object') { + return nodemailer.createTransport({ + host: ctx.smtpHost ?? ctx.host, + port: ctx.smtpPort ?? 587, + secure: false, + auth: { user: ctx.user, pass: ctx.password }, + tls: { rejectUnauthorized: false }, + }); + } + return getEnvSmtpTransport(ctx); +} + +function resolveSenderEmail(ctx: EmailCtx): string { + if (typeof ctx === 'object') return ctx.user; const emailMap: Record = { yahoo: process.env['YAHOO_EMAIL'] ?? '', fetcherpay: process.env['FETCHERPAY_EMAIL'] ?? '', @@ -59,18 +68,18 @@ function getSenderEmail(account: Account = 'yahoo'): string { founder: process.env['FOUNDER_EMAIL'] ?? '', gmail: process.env['GMAIL_EMAIL'] ?? '', }; - return emailMap[account] ?? ''; + return emailMap[ctx] ?? ''; } export async function sendEmail( to: string, subject: string, body: string, - account: Account = 'yahoo', + ctx: EmailCtx = 'yahoo', ): Promise { - const transporter = getSmtpTransport(account); + const transporter = resolveSmtpTransport(ctx); const info = await transporter.sendMail({ - from: getSenderEmail(account), + from: resolveSenderEmail(ctx), to, subject, text: body, @@ -82,52 +91,52 @@ export async function createDraft( to: string, subject: string, body: string, - account: Account = 'yahoo', + ctx: EmailCtx = 'yahoo', ): Promise { const { ImapFlow } = await import('imapflow'); - const fetcherpayImapBase = { - host: process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com', - port: parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993'), - secure: true, - tls: { rejectUnauthorized: false }, - }; - const fetcherpayImapAccounts: Partial> = { - fetcherpay: { user: process.env['FETCHERPAY_EMAIL']!, pass: process.env['FETCHERPAY_PASSWORD']! }, - garfield: { user: process.env['GARFIELD_EMAIL']!, pass: process.env['GARFIELD_PASSWORD']! }, - sales: { user: process.env['SALES_EMAIL']!, pass: process.env['SALES_PASSWORD']! }, - leads: { user: process.env['LEADS_EMAIL']!, pass: process.env['LEADS_PASSWORD']! }, - founder: { user: process.env['FOUNDER_EMAIL']!, pass: process.env['FOUNDER_PASSWORD']! }, - }; - let imapConfig; - if (fetcherpayImapAccounts[account]) { - imapConfig = { ...fetcherpayImapBase, auth: fetcherpayImapAccounts[account]! }; - } else if (account === 'gmail') { + let imapConfig: any; + if (typeof ctx === 'object') { imapConfig = { - host: 'imap.gmail.com', - port: 993, - secure: true, - auth: { - user: process.env['GMAIL_EMAIL']!, - pass: process.env['GMAIL_APP_PASSWORD']!, - }, + host: ctx.host, + port: ctx.port, + secure: ctx.port === 993, + auth: { user: ctx.user, pass: ctx.password }, + tls: { rejectUnauthorized: false }, }; } else { - imapConfig = { - host: 'imap.mail.yahoo.com', - port: 993, + const fetcherpayImapBase = { + host: process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com', + port: parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993'), secure: true, - auth: { - user: process.env['YAHOO_EMAIL']!, - pass: process.env['YAHOO_APP_PASSWORD']!, - }, + tls: { rejectUnauthorized: false }, }; + const fetcherpayAccounts: Partial> = { + fetcherpay: { user: process.env['FETCHERPAY_EMAIL']!, pass: process.env['FETCHERPAY_PASSWORD']! }, + garfield: { user: process.env['GARFIELD_EMAIL']!, pass: process.env['GARFIELD_PASSWORD']! }, + sales: { user: process.env['SALES_EMAIL']!, pass: process.env['SALES_PASSWORD']! }, + leads: { user: process.env['LEADS_EMAIL']!, pass: process.env['LEADS_PASSWORD']! }, + founder: { user: process.env['FOUNDER_EMAIL']!, pass: process.env['FOUNDER_PASSWORD']! }, + }; + if (fetcherpayAccounts[ctx]) { + imapConfig = { ...fetcherpayImapBase, auth: fetcherpayAccounts[ctx]! }; + } else if (ctx === 'gmail') { + imapConfig = { + host: 'imap.gmail.com', port: 993, secure: true, + auth: { user: process.env['GMAIL_EMAIL']!, pass: process.env['GMAIL_APP_PASSWORD']! }, + }; + } else { + imapConfig = { + host: 'imap.mail.yahoo.com', port: 993, secure: true, + auth: { user: process.env['YAHOO_EMAIL']!, pass: process.env['YAHOO_APP_PASSWORD']! }, + }; + } } const client = new ImapFlow(imapConfig); await client.connect(); - const from = getSenderEmail(account); + const from = resolveSenderEmail(ctx); const rawMessage = [ `From: ${from}`, `To: ${to}`, diff --git a/src/tools.test.ts b/src/tools.test.ts new file mode 100644 index 0000000..f7adca6 --- /dev/null +++ b/src/tools.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock all I/O dependencies before importing handleToolCall +vi.mock('./billing/usage.js', () => ({ + recordUsage: vi.fn().mockResolvedValue(undefined), + checkLimit: vi.fn().mockResolvedValue({ allowed: true, limit: 1000, used: 5 }), +})); +vi.mock('./db.js', () => ({ getPool: vi.fn() })); +vi.mock('./imap.js', () => ({ + searchMessages: vi.fn().mockResolvedValue([]), + readMessage: vi.fn().mockResolvedValue({}), + getProfile: vi.fn().mockResolvedValue({ email: 'test@example.com' }), + listFolders: vi.fn().mockResolvedValue([]), +})); +vi.mock('./smtp.js', () => ({ + sendEmail: vi.fn().mockResolvedValue({ messageId: 'abc' }), + createDraft: vi.fn().mockResolvedValue({ id: '1' }), +})); +vi.mock('./clients/obsidian.js', () => ({ + searchNotes: vi.fn().mockResolvedValue([]), + getNote: vi.fn().mockResolvedValue({ content: '' }), + appendToNote: vi.fn().mockResolvedValue({}), + updateNote: vi.fn().mockResolvedValue({}), + getSyncStatus: vi.fn().mockResolvedValue({}), +})); +vi.mock('./clients/whatsapp.js', () => ({ + sendMessage: vi.fn().mockResolvedValue({}), + sendTemplate: vi.fn().mockResolvedValue({}), + getMessageStatus: vi.fn().mockResolvedValue({}), + listTemplates: vi.fn().mockResolvedValue([]), +})); +vi.mock('./clients/linkedin.js', () => ({ + getProfile: vi.fn().mockResolvedValue({}), + createPost: vi.fn().mockResolvedValue({}), + createVideoPost: vi.fn().mockResolvedValue({}), + searchConnections: vi.fn().mockResolvedValue([]), + sendMessage: vi.fn().mockResolvedValue({}), +})); +vi.mock('./clients/telegram.js', () => ({ + getMe: vi.fn().mockResolvedValue({}), + sendMessage: vi.fn().mockResolvedValue({}), + sendPhoto: vi.fn().mockResolvedValue({}), + getUpdates: vi.fn().mockResolvedValue([]), + getChat: vi.fn().mockResolvedValue({}), +})); +vi.mock('./clients/discord.js', () => ({ + getMe: vi.fn().mockResolvedValue({}), + getGuilds: vi.fn().mockResolvedValue([]), + getChannels: vi.fn().mockResolvedValue([]), + sendMessage: vi.fn().mockResolvedValue({}), + getMessages: vi.fn().mockResolvedValue([]), +})); +vi.mock('./clients/instagram.js', () => ({ + getProfile: vi.fn().mockResolvedValue({}), + getMedia: vi.fn().mockResolvedValue([]), + createImagePost: vi.fn().mockResolvedValue({}), + createReel: vi.fn().mockResolvedValue({}), +})); +vi.mock('./clients/twitter.js', () => ({ + searchTweets: vi.fn().mockResolvedValue([]), + getUserProfile: vi.fn().mockResolvedValue({}), + getUserTweets: vi.fn().mockResolvedValue([]), + createTweet: vi.fn().mockResolvedValue({}), + uploadVideoAndTweet: vi.fn().mockResolvedValue({}), +})); +vi.mock('./clients/tiktok.js', () => ({ + getUserProfile: vi.fn().mockResolvedValue({}), + getCreatorInfo: vi.fn().mockResolvedValue({}), + createVideo: vi.fn().mockResolvedValue({}), + getVideoStatus: vi.fn().mockResolvedValue({}), +})); +vi.mock('./clients/snapchat.js', () => ({ + getMe: vi.fn().mockResolvedValue({}), + createSnap: vi.fn().mockResolvedValue({}), + getAdAccounts: vi.fn().mockResolvedValue([]), +})); +vi.mock('./clients/facebook.js', () => ({ + getPage: vi.fn().mockResolvedValue({}), + getPosts: vi.fn().mockResolvedValue([]), + createPost: vi.fn().mockResolvedValue({}), + createPhotoPost: vi.fn().mockResolvedValue({}), + createVideoPost: vi.fn().mockResolvedValue({}), +})); +vi.mock('./redis.js', () => ({ default: { get: vi.fn(), set: vi.fn(), del: vi.fn() } })); +vi.mock('./multitenancy/credential-store.js', () => ({ + getCredential: vi.fn().mockResolvedValue(null), +})); + +import { handleToolCall, stripAccountParam, tools } from './tools.js'; +import { checkLimit, recordUsage } from './billing/usage.js'; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkLimit).mockResolvedValue({ allowed: true, limit: 1000, used: 5 }); +}); + +const mockCustomer = { + id: 'cust-123', + plan: 'growth' as const, + active: true, + email: 'test@example.com', + getCredential: vi.fn().mockResolvedValue(null), +}; + +describe('handleToolCall — plan limit gate', () => { + + it('returns isError when customer is over limit', async () => { + vi.mocked(checkLimit).mockResolvedValue({ allowed: false, limit: 1000, used: 1000 }); + const result = await handleToolCall('get_profile', {}, mockCustomer); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/limit/i); + }); + + it('does not check limit when no customer (unauthenticated dev mode)', async () => { + await handleToolCall('get_profile', {}); + expect(checkLimit).not.toHaveBeenCalled(); + }); + + it('proceeds normally when under limit', async () => { + const result = await handleToolCall('get_profile', {}, mockCustomer); + expect(result.isError).toBeUndefined(); + expect(checkLimit).toHaveBeenCalledWith('cust-123', 'growth'); + }); +}); + +describe('handleToolCall — platform attribution', () => { + + it.each([ + ['send_email', 'email'], + ['create_draft', 'email'], + ['search_messages', 'email'], + ['get_profile', 'email'], + ['list_folders', 'email'], + ['yahoo_send_email', 'email'], + ['linkedin_create_post', 'linkedin'], + ['obsidian_search_notes', 'obsidian'], + ['tiktok_create_video', 'tiktok'], + ['whatsapp_send_message', 'whatsapp'], + ['telegram_send_message', 'telegram'], + ['discord_send_message', 'discord'], + ['instagram_create_post', 'instagram'], + ['twitter_create_tweet', 'twitter'], + ['snapchat_create_snap', 'snapchat'], + ['facebook_create_post', 'facebook'], + ])('%s → platform "%s"', async (toolName, expectedPlatform) => { + await handleToolCall(toolName, {}, mockCustomer).catch(() => {}); + const calls = vi.mocked(recordUsage).mock.calls; + const last = calls[calls.length - 1]; + if (last) { + expect(last[1]).toBe(expectedPlatform); + } + }); +}); + +describe('handleToolCall — error handling', () => { + it('returns isError: true on tool exception', async () => { + const { getProfile } = await import('./imap.js'); + vi.mocked(getProfile).mockRejectedValueOnce(new Error('IMAP connection refused')); + const result = await handleToolCall('get_profile', {}); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('IMAP connection refused'); + }); +}); + +describe('stripAccountParam', () => { + it('removes account from inputSchema.properties', () => { + const tool = tools.find(t => 'account' in (t.inputSchema.properties ?? {}))!; + expect(tool).toBeDefined(); + const stripped = stripAccountParam(tool); + expect(stripped.inputSchema.properties).not.toHaveProperty('account'); + }); + + it('does not mutate the original tool', () => { + const tool = tools.find(t => 'account' in (t.inputSchema.properties ?? {}))!; + const before = Object.keys(tool.inputSchema.properties ?? {}); + stripAccountParam(tool); + const after = Object.keys(tool.inputSchema.properties ?? {}); + expect(after).toEqual(before); + }); + + it('preserves all other properties', () => { + const tool = tools.find(t => 'account' in (t.inputSchema.properties ?? {}))!; + const stripped = stripAccountParam(tool); + const originalWithoutAccount = Object.fromEntries( + Object.entries(tool.inputSchema.properties ?? {}).filter(([k]) => k !== 'account') + ); + expect(stripped.inputSchema.properties).toEqual(originalWithoutAccount); + expect(stripped.name).toBe(tool.name); + expect(stripped.description).toBe(tool.description); + }); + + it('handles tools with no account param safely', () => { + const tool = tools.find(t => !('account' in (t.inputSchema.properties ?? {})))!; + expect(tool).toBeDefined(); + const stripped = stripAccountParam(tool); + expect(stripped.inputSchema.properties).toEqual(tool.inputSchema.properties); + }); + + it('all tools in multi-tenant mode have no account param', () => { + const stripped = tools.map(stripAccountParam); + for (const t of stripped) { + expect(t.inputSchema.properties).not.toHaveProperty('account'); + } + }); +}); diff --git a/src/tools.ts b/src/tools.ts index 7fd3766..747a52a 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1,8 +1,9 @@ import { Tool } from '@modelcontextprotocol/sdk/types.js'; import type { Customer } from './billing/middleware.js'; -import { recordUsage } from './billing/usage.js'; -import { searchMessages, readMessage, getProfile, listFolders, type Account } from './imap.js'; +import { recordUsage, checkLimit } from './billing/usage.js'; +import { searchMessages, readMessage, getProfile, listFolders, type Account, type EmailCtx } from './imap.js'; import { sendEmail, createDraft } from './smtp.js'; +import type { EmailCredentials } from './multitenancy/credential-store.js'; import { searchNotes, getNote, appendToNote, updateNote, getSyncStatus } from './clients/obsidian.js'; import { sendMessage, sendTemplate, getMessageStatus, listTemplates } from './clients/whatsapp.js'; import { getProfile as getLinkedInProfile, createPost as createLinkedInPost, createVideoPost as createLinkedInVideoPost, searchConnections, sendMessage as sendLinkedInMessage } from './clients/linkedin.js'; @@ -727,40 +728,81 @@ function acct(args: Record): Account { return (args.account as Account) ?? 'yahoo'; } +async function resolveEmailCtx(args: Record, customer?: Customer): Promise { + if (customer) { + const creds = await customer.getCredential('email'); + if (creds) return creds; + } + return acct(args); +} + +const PLATFORM_PREFIXES = [ + 'linkedin', 'obsidian', 'whatsapp', 'telegram', 'discord', + 'instagram', 'twitter', 'tiktok', 'snapchat', 'facebook', +]; + +function toolPlatform(name: string): string { + const prefix = name.split('_')[0]; + return PLATFORM_PREFIXES.includes(prefix) ? prefix : 'email'; +} + export async function handleToolCall( name: string, args: Record, customer?: Customer -): Promise<{ content: Array<{ type: string; text: string }> }> { +): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> { console.log(`[tool] ${name}`, JSON.stringify(args)); const t0 = Date.now(); + + if (customer) { + const { allowed } = await checkLimit(customer.id, customer.plan); + if (!allowed) { + return { + content: [{ type: 'text', text: 'Monthly tool call limit reached. Please upgrade your plan.' }], + isError: true, + }; + } + } + try { let result: unknown; switch (name) { - case 'get_profile': - result = await getProfile(acct(args)); + case 'get_profile': { + const emailCtx = await resolveEmailCtx(args, customer); + result = await getProfile(emailCtx); break; + } - case 'search_messages': - result = await searchMessages(args.q as string, (args.maxResults as number) ?? 20, acct(args), args.folder as string | undefined); + case 'search_messages': { + const emailCtx = await resolveEmailCtx(args, customer); + result = await searchMessages(args.q as string, (args.maxResults as number) ?? 20, emailCtx, args.folder as string | undefined); break; + } - case 'read_message': - result = await readMessage(args.uid as number, acct(args), args.folder as string | undefined); + case 'read_message': { + const emailCtx = await resolveEmailCtx(args, customer); + result = await readMessage(args.uid as number, emailCtx, args.folder as string | undefined); break; + } - case 'list_folders': - result = await listFolders(acct(args)); + case 'list_folders': { + const emailCtx = await resolveEmailCtx(args, customer); + result = await listFolders(emailCtx); break; + } - case 'create_draft': - result = await createDraft(args.to as string, args.subject as string, args.body as string, acct(args)); + case 'create_draft': { + const emailCtx = await resolveEmailCtx(args, customer); + result = await createDraft(args.to as string, args.subject as string, args.body as string, emailCtx); break; + } - case 'send_email': - result = await sendEmail(args.to as string, args.subject as string, args.body as string, acct(args)); + case 'send_email': { + const emailCtx = await resolveEmailCtx(args, customer); + result = await sendEmail(args.to as string, args.subject as string, args.body as string, emailCtx); break; + } // ── Obsidian ────────────────────────────────────────────────────────── case 'obsidian_search_notes': @@ -1126,8 +1168,7 @@ export async function handleToolCall( console.log(`[tool] ${name} OK (${Date.now() - t0}ms)`); if (customer) { - const platform = name.split('_')[0]; - recordUsage(customer.id, platform, name).catch(() => {}); + recordUsage(customer.id, toolPlatform(name), name).catch(() => {}); } return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], @@ -1139,6 +1180,19 @@ export async function handleToolCall( console.error(stack); return { content: [{ type: 'text', text: `Error: ${msg}` }], + isError: true, }; } } + +export function stripAccountParam(tool: Tool): Tool { + const props = tool.inputSchema.properties ?? {}; + const filtered = Object.fromEntries(Object.entries(props).filter(([k]) => k !== 'account')); + return { + ...tool, + inputSchema: { + ...tool.inputSchema, + properties: filtered as Tool['inputSchema']['properties'], + }, + }; +} diff --git a/src/webhooks/delivery.test.ts b/src/webhooks/delivery.test.ts new file mode 100644 index 0000000..02e894c --- /dev/null +++ b/src/webhooks/delivery.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { mockQuery, mockRPush, mockExpire } = vi.hoisted(() => ({ + mockQuery: vi.fn(), + mockRPush: vi.fn(), + mockExpire: vi.fn(), +})); + +vi.mock('../db.js', () => ({ getPool: vi.fn(() => ({ query: mockQuery })) })); +vi.mock('../redis.js', () => ({ + default: { rPush: mockRPush, expire: mockExpire }, +})); + +global.fetch = vi.fn(); + +import { deliverWebhook, isValidWebhookUrl } from './delivery.js'; + +// ── URL validation ────────────────────────────────────────────── + +describe('isValidWebhookUrl', () => { + it('accepts https:// with public hostname', () => { + expect(isValidWebhookUrl('https://my-server.example.com/hook')).toBe(true); + }); + + it('rejects ftp:// and other non-http schemes', () => { + expect(isValidWebhookUrl('ftp://example.com/hook')).toBe(false); + }); + + it('rejects 127.x loopback', () => { + expect(isValidWebhookUrl('http://127.0.0.1/hook')).toBe(false); + }); + + it('rejects 10.x private range', () => { + expect(isValidWebhookUrl('https://10.0.0.1/hook')).toBe(false); + }); + + it('rejects 192.168.x private range', () => { + expect(isValidWebhookUrl('https://192.168.1.1/hook')).toBe(false); + }); + + it('rejects 172.16-31.x private range', () => { + expect(isValidWebhookUrl('https://172.16.0.1/hook')).toBe(false); + expect(isValidWebhookUrl('https://172.31.255.255/hook')).toBe(false); + }); + + it('rejects localhost hostname', () => { + expect(isValidWebhookUrl('http://localhost/hook')).toBe(false); + }); + + it('rejects .local domains', () => { + expect(isValidWebhookUrl('http://myserver.local/hook')).toBe(false); + }); + + it('rejects invalid URLs', () => { + expect(isValidWebhookUrl('not-a-url')).toBe(false); + }); +}); + +// ── deliverWebhook ────────────────────────────────────────────── + +describe('deliverWebhook', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + mockRPush.mockResolvedValue(1); + mockExpire.mockResolvedValue(1); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does nothing when customer has no webhook_url', async () => { + mockQuery.mockResolvedValue([[{ webhook_url: null, webhook_secret: null }]]); + await deliverWebhook('cust-1', 'whatsapp', 'inbound_message', { from: '+1234' }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('does nothing when customer not found', async () => { + mockQuery.mockResolvedValue([[]]); + await deliverWebhook('cust-1', 'whatsapp', 'inbound_message', { from: '+1234' }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('POSTs to webhook_url with correct headers on success', async () => { + mockQuery.mockResolvedValue([[{ + webhook_url: 'https://example.com/hook', + webhook_secret: 'secret123', + }]]); + (fetch as ReturnType).mockResolvedValue({ ok: true }); + + await deliverWebhook('cust-1', 'whatsapp', 'inbound_message', { from: '+1234', text: 'hi' }); + + expect(fetch).toHaveBeenCalledTimes(1); + const [url, opts] = (fetch as ReturnType).mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://example.com/hook'); + expect((opts.headers as Record)['Content-Type']).toBe('application/json'); + expect((opts.headers as Record)['X-SquareMCP-Signature']).toMatch(/^sha256=[0-9a-f]{64}$/); + }); + + it('sends correct HMAC signature', async () => { + const secret = 'mysecret'; + mockQuery.mockResolvedValue([[{ webhook_url: 'https://example.com/hook', webhook_secret: secret }]]); + (fetch as ReturnType).mockResolvedValue({ ok: true }); + + await deliverWebhook('cust-1', 'whatsapp', 'inbound_message', { from: '+1' }); + + const [, opts] = (fetch as ReturnType).mock.calls[0] as [string, RequestInit]; + const sig = (opts.headers as Record)['X-SquareMCP-Signature']; + const body = opts.body as string; + + // Verify the signature independently + const { createHmac } = await import('crypto'); + const expected = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`; + expect(sig).toBe(expected); + }); + + it('retries on failure and pushes to DLQ after all attempts', async () => { + mockQuery.mockResolvedValue([[{ webhook_url: 'https://example.com/hook', webhook_secret: 'sec' }]]); + (fetch as ReturnType).mockResolvedValue({ ok: false, status: 500 }); + + const deliverPromise = deliverWebhook('cust-1', 'whatsapp', 'inbound_message', { from: '+1' }); + + // Advance timers through all retry delays: 1s, 4s, 16s + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(4000); + await vi.advanceTimersByTimeAsync(16000); + await deliverPromise; + + expect(fetch).toHaveBeenCalledTimes(4); // 1 initial + 3 retries + expect(mockRPush).toHaveBeenCalledWith( + 'webhook:dlq:cust-1', + expect.stringContaining('"customerId":"cust-1"') + ); + expect(mockExpire).toHaveBeenCalledWith('webhook:dlq:cust-1', 604800); + }); + + it('does not push to DLQ on first-attempt success', async () => { + mockQuery.mockResolvedValue([[{ webhook_url: 'https://example.com/hook', webhook_secret: 'sec' }]]); + (fetch as ReturnType).mockResolvedValue({ ok: true }); + + await deliverWebhook('cust-1', 'whatsapp', 'inbound_message', { from: '+1' }); + + expect(mockRPush).not.toHaveBeenCalled(); + expect(mockExpire).not.toHaveBeenCalled(); + }); + + it('succeeds on second attempt (one initial failure)', async () => { + mockQuery.mockResolvedValue([[{ webhook_url: 'https://example.com/hook', webhook_secret: 'sec' }]]); + (fetch as ReturnType) + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce({ ok: true }); + + const deliverPromise = deliverWebhook('cust-1', 'whatsapp', 'inbound_message', { from: '+1' }); + await vi.advanceTimersByTimeAsync(1000); + await deliverPromise; + + expect(fetch).toHaveBeenCalledTimes(2); + expect(mockRPush).not.toHaveBeenCalled(); + }); +}); diff --git a/src/webhooks/delivery.ts b/src/webhooks/delivery.ts new file mode 100644 index 0000000..5c08ff4 --- /dev/null +++ b/src/webhooks/delivery.ts @@ -0,0 +1,84 @@ +import crypto from 'crypto'; +import redis from '../redis.js'; +import { getPool } from '../db.js'; + +const RETRY_DELAYS_MS = [1000, 4000, 16000]; +const DLQ_TTL_SECONDS = 604800; // 7 days + +export interface WebhookPayload { + customerId: string; + platform: string; + event: string; + data: Record; +} + +export function isValidWebhookUrl(url: string): boolean { + try { + const parsed = new URL(url); + if (!['http:', 'https:'].includes(parsed.protocol)) return false; + const host = parsed.hostname; + if (host === 'localhost' || host === '0.0.0.0' || host.endsWith('.local')) return false; + // Block RFC-1918 private ranges and loopback + if (/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host)) return false; + return true; + } catch { + return false; + } +} + +function signPayload(secret: string, payload: string): string { + return `sha256=${crypto.createHmac('sha256', secret).update(payload).digest('hex')}`; +} + +async function postWithRetry(url: string, payload: string, signature: string): Promise { + for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) { + if (attempt > 0) { + await new Promise(r => setTimeout(r, RETRY_DELAYS_MS[attempt - 1])); + } + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-SquareMCP-Signature': signature }, + body: payload, + signal: AbortSignal.timeout(10_000), + }); + if (res.ok) return true; + console.warn(`[webhook] attempt ${attempt + 1} HTTP ${res.status} → ${url}`); + } catch (err) { + console.warn(`[webhook] attempt ${attempt + 1} error:`, (err as Error).message); + } + } + return false; +} + +export async function deliverWebhook( + customerId: string, + platform: string, + event: string, + data: Record +): Promise { + const [rows] = await getPool().query( + 'SELECT webhook_url, webhook_secret FROM customers WHERE id = ?', + [customerId] + ); + if (!rows.length || !rows[0].webhook_url || !rows[0].webhook_secret) return; + + const { webhook_url, webhook_secret } = rows[0] as { webhook_url: string; webhook_secret: string }; + const payload: WebhookPayload = { customerId, platform, event, data }; + const payloadStr = JSON.stringify(payload); + const signature = signPayload(webhook_secret, payloadStr); + + const delivered = await postWithRetry(webhook_url, payloadStr, signature); + + if (!delivered) { + console.error(`[webhook] all attempts failed for customer ${customerId}, pushing to DLQ`); + const dlqKey = `webhook:dlq:${customerId}`; + const entry = JSON.stringify({ + payload, + failedAt: new Date().toISOString(), + attempts: RETRY_DELAYS_MS.length + 1, + }); + await redis.rPush(dlqKey, entry); + await redis.expire(dlqKey, DLQ_TTL_SECONDS); + } +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..014f97e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + }, +});