diff --git a/.env.example b/.env.example index c0a6ca8..834543a 100644 --- a/.env.example +++ b/.env.example @@ -81,3 +81,20 @@ INSTAGRAM_DEFAULT_BUSINESS_ACCOUNT_ID=your-instagram-business-account-id # For default account: TWITTER_DEFAULT_BEARER_TOKEN=your-twitter-bearer-token # For additional accounts, duplicate with TWITTER_{ACCOUNT}_* + +# ── TikTok Content Posting API ─────────────────────────────────────────────── +# Get an access token from the TikTok developer app with Content Posting scopes +# Login Kit / OAuth app credentials: +TIKTOK_CLIENT_KEY=your-tiktok-client-key +TIKTOK_CLIENT_SECRET=your-tiktok-client-secret +TIKTOK_REDIRECT_URI=https://tiktok.squaremcp.com/auth/tiktok/callback +# For default account: +TIKTOK_DEFAULT_ACCESS_TOKEN=your-tiktok-access-token +# For additional accounts, duplicate with TIKTOK_{ACCOUNT}_* + +# ── Facebook Graph API ─────────────────────────────────────────────────────── +# Use a Page access token with pages_manage_posts + pages_read_engagement +# For default account: +FACEBOOK_DEFAULT_ACCESS_TOKEN=your-facebook-page-access-token +FACEBOOK_DEFAULT_PAGE_ID=your-facebook-page-id +# For additional accounts, duplicate with FACEBOOK_{ACCOUNT}_* diff --git a/.gitignore b/.gitignore index 86f795b..313d7d0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ opencode.json # Remotion demo project videos/remotion-demo/node_modules +videos/remotion-demo/build videos/remotion-demo/out diff --git a/README.md b/README.md index 3e09cca..5c44772 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Use the setup guide that matches your client: 2. [CLI agent setup (Claude Code, generic MCP CLIs, Claude Desktop)](./AGENTS_CLI_SETUP.md) 3. [opencode setup](./OPENCODE.md) 4. [ChatGPT Custom GPT setup](./CHATGPT_SETUP.md) +5. [Social publishing setup (TikTok / Facebook)](./SOCIAL_PUBLISHING_SETUP.md) --- @@ -129,6 +130,7 @@ SquareMCP product-site docs live under: 1. [`product/site`](./product/site) 2. [`product/README.md`](./product/README.md) +3. [`videos/remotion-demo`](./videos/remotion-demo/README.md) for SquareMCP video production assets and render workflows --- diff --git a/SETUP_GUIDE.md b/SETUP_GUIDE.md new file mode 100644 index 0000000..0689579 --- /dev/null +++ b/SETUP_GUIDE.md @@ -0,0 +1,321 @@ +# Hermes MCP — Platform Setup Guide + +How to obtain credentials and configure each social platform integration. + +--- + +## Table of Contents + +- [TikTok](#tiktok) +- [Facebook](#facebook) +- [Instagram](#instagram) +- [LinkedIn](#linkedin) +- [Twitter / X](#twitter--x) +- [Discord](#discord) +- [Telegram](#telegram) +- [WhatsApp](#whatsapp-meta-cloud-api) +- [Email (IMAP/SMTP)](#email-imapsmtp) +- [Obsidian](#obsidian) +- [Quick Reference: All Env Vars](#quick-reference-all-env-vars) + +--- + +## TikTok + +### Prerequisites +- TikTok Developer account at [developers.tiktok.com](https://developers.tiktok.com) +- App created in Sandbox or Production mode + +### Steps +1. Go to **Developer Portal → Manage Apps → Your App** +2. Add **Login Kit** product → set redirect URI: `https://tiktok.squaremcp.com/auth/tiktok/callback` +3. Add **Content Posting API** product → enable Direct Post +4. Configure scopes: `user.info.basic`, `user.info.profile`, `user.info.stats`, `video.list`, `video.publish` +5. Add domain verification files to site root (TikTok provides `.txt` files) +6. Add sandbox test users under **Sandbox → Target Users** + +### Credentials +| Env Var | Source | +|---|---| +| `TIKTOK_CLIENT_KEY` | App → Basic Information → Client Key | +| `TIKTOK_CLIENT_SECRET` | App → Basic Information → Client Secret | +| `TIKTOK_REDIRECT_URI` | `https://tiktok.squaremcp.com/auth/tiktok/callback` | +| `TIKTOK_DEFAULT_ACCESS_TOKEN` | Complete OAuth flow → copy access_token from callback | + +--- + +## Facebook + +### Prerequisites +- Facebook Developer account +- Facebook Page (Business/Brand page, not personal profile) +- Admin role on the page + +### Steps +1. Go to [developers.facebook.com](https://developers.facebook.com) → **My Apps → Create App** +2. App type: **Business** → add **Pages** product +3. Go to **Graph API Explorer** → select your app → generate token with permissions: + - `pages_show_list` + - `pages_read_engagement` + - `pages_manage_posts` + - `publish_video` +4. Run `GET /me/accounts` to list pages → copy the **Page Access Token** (not User Token) +5. Copy the **Page ID** from the same response + +### Credentials +| Env Var | Source | +|---|---| +| `FACEBOOK_DEFAULT_ACCESS_TOKEN` | Page Access Token from `/me/accounts` | +| `FACEBOOK_DEFAULT_PAGE_ID` | `id` field from `/me/accounts` response | + +--- + +## Instagram + +### Prerequisites +- Instagram **Business** or **Creator** account (personal accounts do not work) +- Instagram account connected to a Facebook Page you admin +- Same Facebook App as above, with **Instagram Graph API** product added + +### Steps +1. In Instagram app: Profile → Menu → Settings → Account → Switch to Professional Account → **Business** +2. Connect to Facebook Page under **Settings → Creator tools and controls → Set up Instagram Business Profile** +3. In Facebook Developer Portal: add **Instagram Graph API** product to your app +4. Open **Graph API Explorer** → select app → generate token with: + - `instagram_basic` + - `instagram_content_publish` + - `pages_read_engagement` +5. Run: `GET me/accounts?fields=name,instagram_business_account` +6. Find your page → copy `instagram_business_account.id` +7. Copy the **Access Token** from the explorer + +### Credentials +| Env Var | Source | +|---|---| +| `INSTAGRAM_DEFAULT_ACCESS_TOKEN` | Graph API Explorer token with `instagram_basic` | +| `INSTAGRAM_DEFAULT_BUSINESS_ACCOUNT_ID` | `instagram_business_account.id` from `/me/accounts` | + +--- + +## LinkedIn + +### Prerequisites +- LinkedIn Developer account +- App created at [developer.linkedin.com](https://developer.linkedin.com) + +### Steps +1. Create app → add **Sign In with LinkedIn using OpenID Connect** product +2. Set redirect URI: `https://hermes.squaremcp.com/oauth/callback` +3. Request **Share on LinkedIn** product for posting permissions +4. Generate a 3-legged OAuth token with scopes: `openid`, `profile`, `w_member_social` +5. Copy the **Access Token** + +### Credentials +| Env Var | Source | +|---|---| +| `LINKEDIN_DEFAULT_ACCESS_TOKEN` | OAuth 2.0 token from LinkedIn Developer Portal | + +--- + +## Twitter / X + +### Prerequisites +- Twitter Developer account at [developer.x.com](https://developer.x.com) +- Project and App created + +### Steps +1. Create project → create app inside project +2. Enable **User authentication settings** → OAuth 2.0 → set callback URL +3. Permissions: **Read and Write** +4. Go to **Keys and Tokens** → generate **User Access Tokens** +5. Copy **Access Token** and **Access Token Secret** + +### Credentials +| Env Var | Source | +|---|---| +| `TWITTER_DEFAULT_ACCESS_TOKEN` | Keys and Tokens → Access Token | +| `TWITTER_DEFAULT_ACCESS_TOKEN_SECRET` | Keys and Tokens → Access Token Secret | +| `TWITTER_DEFAULT_API_KEY` | Keys and Tokens → API Key | +| `TWITTER_DEFAULT_API_SECRET` | Keys and Tokens → API Secret | + +--- + +## Discord + +### Prerequisites +- Discord account + +### Steps +1. Go to [discord.com/developers/applications](https://discord.com/developers/applications) +2. Click **New Application** → name it (e.g., "SquareMCP Bot") +3. Go to **Bot** tab → click **Add Bot** +4. Under **Privileged Gateway Intents**, enable **MESSAGE CONTENT INTENT** +5. Click **Reset Token** → copy the **Bot Token** +6. Go to **OAuth2 → URL Generator**: + - Scopes: `bot` + - Bot Permissions: `Send Messages`, `Read Message History`, `View Channels` +7. Copy the generated URL and open it in browser to invite the bot to your server + +### Credentials +| Env Var | Source | +|---|---| +| `DISCORD_DEFAULT_BOT_TOKEN` | Bot tab → Token | + +--- + +## Telegram + +### Prerequisites +- Telegram account + +### Steps +1. Open Telegram → search **@BotFather** +2. Send `/newbot` → follow prompts → pick username (must end in `bot`) +3. BotFather sends you a **Bot Token** (e.g., `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`) +4. Copy the token + +### Credentials +| Env Var | Source | +|---|---| +| `TELEGRAM_DEFAULT_BOT_TOKEN` | BotFather message after creating bot | + +--- + +## WhatsApp (Meta Cloud API) + +### Prerequisites +- Meta Business Account +- WhatsApp Business Account (WABA) +- Phone number registered with WhatsApp Business Platform + +### Steps +1. Go to [business.facebook.com](https://business.facebook.com) → **WhatsApp → API Setup** +2. Create/select a WhatsApp Business Account +3. Add a phone number → verify via SMS/voice call +4. Go to **Configuration** → generate a **Permanent Access Token**: + - You need a System User in Meta Business Settings + - Assign WhatsApp Business Management permission + - Generate token with `whatsapp_business_management` and `whatsapp_business_messaging` +5. Copy: + - **Phone Number ID** + - **WhatsApp Business Account ID** (WABA ID) + - **Access Token** + +### Credentials +| Env Var | Source | +|---|---| +| `WHATSAPP_DEFAULT_ACCESS_TOKEN` | Meta Business → System User → Permanent Token | +| `WHATSAPP_DEFAULT_PHONE_NUMBER_ID` | WhatsApp API Setup → Phone Number ID | +| `WHATSAPP_DEFAULT_BUSINESS_ACCOUNT_ID` | WhatsApp API Setup → WABA ID | + +--- + +## Email (IMAP/SMTP) + +### Supported Providers +Yahoo, Gmail, Outlook/Exchange, GoDaddy, any IMAP/SMTP provider. + +### Per-Account Env Vars +Replace `{ACCOUNT}` with the account nickname (e.g., `YAHOO`, `GMAIL`, `FETCHERPAY`): + +| Env Var | Example | +|---|---| +| `{ACCOUNT}_EMAIL` | `gheron01@yahoo.com` | +| `{ACCOUNT}_PASSWORD` | App-specific password | +| `{ACCOUNT}_IMAP_HOST` | `imap.mail.yahoo.com` | +| `{ACCOUNT}_IMAP_PORT` | `993` | +| `{ACCOUNT}_SMTP_HOST` | `smtp.mail.yahoo.com` | +| `{ACCOUNT}_SMTP_PORT` | `465` or `587` | + +### Notes +- Gmail requires an **App Password** (not your regular Google password) +- Yahoo requires an **App Password** generated in Account Security settings +- GoDaddy uses `imap.secureserver.net` and `smtpout.secureserver.net` + +--- + +## Obsidian + +### Prerequisites +- Obsidian vault with Syncthing enabled +- Syncthing API key and folder ID + +### Steps +1. Install Syncthing on the server hosting Hermes +2. In Obsidian: Settings → Syncthing → copy **API Key** and **Folder ID** +3. Set vault path on the server + +### Credentials +| Env Var | Source | +|---|---| +| `OBSIDIAN_VAULT_PATH` | Absolute path to vault on server | +| `SYNCTHING_API_KEY` | Syncthing GUI → Settings → API Key | +| `SYNCTHING_FOLDER_ID` | Syncthing GUI → Folders → Folder ID | +| `SYNCTHING_URL` | `http://localhost:8384` | + +--- + +## Quick Reference: All Env Vars + +```bash +# TikTok +TIKTOK_CLIENT_KEY= +TIKTOK_CLIENT_SECRET= +TIKTOK_REDIRECT_URI=https://tiktok.squaremcp.com/auth/tiktok/callback +TIKTOK_DEFAULT_ACCESS_TOKEN= + +# Facebook +FACEBOOK_DEFAULT_ACCESS_TOKEN= +FACEBOOK_DEFAULT_PAGE_ID= + +# Instagram +INSTAGRAM_DEFAULT_ACCESS_TOKEN= +INSTAGRAM_DEFAULT_BUSINESS_ACCOUNT_ID= + +# LinkedIn +LINKEDIN_DEFAULT_ACCESS_TOKEN= + +# Twitter/X +TWITTER_DEFAULT_ACCESS_TOKEN= +TWITTER_DEFAULT_ACCESS_TOKEN_SECRET= +TWITTER_DEFAULT_API_KEY= +TWITTER_DEFAULT_API_SECRET= + +# Discord +DISCORD_DEFAULT_BOT_TOKEN= + +# Telegram +TELEGRAM_DEFAULT_BOT_TOKEN= + +# WhatsApp +WHATSAPP_DEFAULT_ACCESS_TOKEN= +WHATSAPP_DEFAULT_PHONE_NUMBER_ID= +WHATSAPP_DEFAULT_BUSINESS_ACCOUNT_ID= + +# Email (repeat pattern for each account) +YAHOO_EMAIL= +YAHOO_PASSWORD= +YAHOO_IMAP_HOST=imap.mail.yahoo.com +YAHOO_IMAP_PORT=993 +YAHOO_SMTP_HOST=smtp.mail.yahoo.com +YAHOO_SMTP_PORT=465 + +# Obsidian +OBSIDIAN_VAULT_PATH= +SYNCTHING_API_KEY= +SYNCTHING_FOLDER_ID= +SYNCTHING_URL=http://localhost:8384 + +# Database +MYSQL_HOST= +MYSQL_PORT=3306 +MYSQL_USER= +MYSQL_PASSWORD= + +# Redis +REDIS_URL=redis://localhost:6379 + +# Security +MCP_API_KEY= +CREDENTIAL_ENCRYPTION_KEY= +``` diff --git a/SOCIAL_PUBLISHING_SETUP.md b/SOCIAL_PUBLISHING_SETUP.md new file mode 100644 index 0000000..e734636 --- /dev/null +++ b/SOCIAL_PUBLISHING_SETUP.md @@ -0,0 +1,118 @@ +# Social Publishing Setup + +This project can publish directly to TikTok and Facebook once the platform credentials are connected. + +## Public video asset + +The current SquareMCP TikTok master render is intended to be served from: + +```text +https://squaremcp.com/squaremcp-tiktok-launch.mp4 +``` + +TikTok and Facebook both use a pull-from-URL style publish flow in Hermes, so the video must be publicly reachable. + +## TikTok credentials + +Hermes accepts TikTok credentials in either of these forms: + +1. Environment variable: + +```text +TIKTOK_CLIENT_KEY=... +TIKTOK_CLIENT_SECRET=... +TIKTOK_REDIRECT_URI=https://tiktok.squaremcp.com/auth/tiktok/callback +TIKTOK_DEFAULT_ACCESS_TOKEN=... +``` + +Login Kit start URL: + +```text +https://tiktok.squaremcp.com/auth/tiktok/start +``` + +Login Kit callback URL: + +```text +https://tiktok.squaremcp.com/auth/tiktok/callback +``` + +2. Per-customer connection: + +```bash +curl -X POST https://hermes.squaremcp.com/api/connect/tiktok \ + -H 'x-api-key: YOUR_MCP_API_KEY' \ + -H 'Content-Type: application/json' \ + -d '{ + "accessToken": "YOUR_TIKTOK_ACCESS_TOKEN" + }' +``` + +Publish a video: + +```bash +curl -X POST https://hermes.squaremcp.com/api/tiktok/video \ + -H 'x-api-key: YOUR_MCP_API_KEY' \ + -H 'Content-Type: application/json' \ + -d '{ + "video_url": "https://squaremcp.com/squaremcp-tiktok-launch.mp4", + "title": "SquareMCP launch", + "description": "One API key. One workflow layer. #SquareMCP #AITools #Automation" + }' +``` + +Check publish status: + +```bash +curl -X POST https://hermes.squaremcp.com/api/tiktok/video/status \ + -H 'x-api-key: YOUR_MCP_API_KEY' \ + -H 'Content-Type: application/json' \ + -d '{ + "publish_id": "PUBLISH_ID_FROM_CREATE_VIDEO" + }' +``` + +## Facebook credentials + +Hermes accepts Facebook credentials in either of these forms: + +1. Environment variables: + +```text +FACEBOOK_DEFAULT_ACCESS_TOKEN=... +FACEBOOK_DEFAULT_PAGE_ID=... +``` + +2. Per-customer connection: + +```bash +curl -X POST https://hermes.squaremcp.com/api/connect/facebook \ + -H 'x-api-key: YOUR_MCP_API_KEY' \ + -H 'Content-Type: application/json' \ + -d '{ + "accessToken": "YOUR_FACEBOOK_PAGE_ACCESS_TOKEN", + "pageId": "YOUR_FACEBOOK_PAGE_ID" + }' +``` + +Publish the SquareMCP video to Facebook: + +```bash +curl -X POST https://hermes.squaremcp.com/api/facebook/video \ + -H 'x-api-key: YOUR_MCP_API_KEY' \ + -H 'Content-Type: application/json' \ + -d '{ + "video_url": "https://squaremcp.com/squaremcp-tiktok-launch.mp4", + "description": "SquareMCP launch video. One API key. Real tool access." + }' +``` + +## Required platform values + +TikTok: +- access token with Content Posting API access + +Facebook: +- Page access token with `pages_manage_posts` +- Page access token with `pages_read_engagement` +- Page ID diff --git a/package-lock.json b/package-lock.json index 761e5a7..c9a69ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,16 +10,22 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", "@types/cors": "^2.8.19", + "bcryptjs": "^3.0.3", + "cookie-parser": "^1.4.7", "cors": "^2.8.6", "dotenv": "^16.0.0", "express": "^4.18.0", "imapflow": "^1.0.0", + "jsonwebtoken": "^9.0.3", "mysql2": "^3.14.0", "nodemailer": "^6.9.0", "redis": "^5.12.1" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.10", "@types/express": "^4.17.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.0.0", "@types/nodemailer": "^6.4.0", "pixelmatch": "^7.1.0", @@ -886,6 +892,13 @@ "@redis/client": "^5.12.1" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -907,6 +920,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -949,6 +972,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -956,6 +990,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.37", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", @@ -1103,6 +1144,15 @@ "node": ">= 6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -1142,6 +1192,12 @@ "node": ">= 0.8" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1219,6 +1275,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", @@ -1319,6 +1394,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1838,6 +1922,55 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/libbase64": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", @@ -1874,6 +2007,48 @@ "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -2447,6 +2622,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", diff --git a/package.json b/package.json index 76a5c94..238f9aa 100644 --- a/package.json +++ b/package.json @@ -18,16 +18,22 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", "@types/cors": "^2.8.19", + "bcryptjs": "^3.0.3", + "cookie-parser": "^1.4.7", "cors": "^2.8.6", "dotenv": "^16.0.0", "express": "^4.18.0", "imapflow": "^1.0.0", + "jsonwebtoken": "^9.0.3", "mysql2": "^3.14.0", "nodemailer": "^6.9.0", "redis": "^5.12.1" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.10", "@types/express": "^4.17.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.0.0", "@types/nodemailer": "^6.4.0", "pixelmatch": "^7.1.0", diff --git a/product/app/Dockerfile b/product/app/Dockerfile new file mode 100644 index 0000000..fa05c57 --- /dev/null +++ b/product/app/Dockerfile @@ -0,0 +1,6 @@ +FROM nginx:1.27-alpine +COPY product/app/nginx-app.conf /etc/nginx/conf.d/default.conf +COPY product/app/index.html /usr/share/nginx/html/index.html +COPY product/app/styles.css /usr/share/nginx/html/styles.css +COPY product/app/app.js /usr/share/nginx/html/app.js +EXPOSE 8080 diff --git a/product/app/app-k8s.yaml b/product/app/app-k8s.yaml new file mode 100644 index 0000000..c47c641 --- /dev/null +++ b/product/app/app-k8s.yaml @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: squaremcp-app + namespace: fetcherpay +spec: + replicas: 1 + selector: + matchLabels: + app: squaremcp-app + template: + metadata: + labels: + app: squaremcp-app + spec: + containers: + - name: squaremcp-app + image: localhost:32000/squaremcp-app@sha256:45d7adfe10efe727ec1f6c1f5a64ad12d9aa426af90145b5f8d7c1a9dbbe9536 + imagePullPolicy: Always + ports: + - containerPort: 8080 + readinessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 3 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: squaremcp-app + namespace: fetcherpay +spec: + selector: + app: squaremcp-app + ports: + - protocol: TCP + port: 80 + targetPort: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: squaremcp-app-ingress + namespace: fetcherpay + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + ingressClassName: nginx + rules: + - host: app.squaremcp.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: squaremcp-app + port: + number: 80 + tls: + - hosts: + - app.squaremcp.com + secretName: squaremcp-app-tls diff --git a/product/app/app.js b/product/app/app.js new file mode 100644 index 0000000..43fb3d2 --- /dev/null +++ b/product/app/app.js @@ -0,0 +1,532 @@ +const API_BASE = 'https://hermes.squaremcp.com'; + +// State +let currentUser = null; +let isAdmin = false; + +// DOM refs +const loginView = document.getElementById('login-view'); +const resetRequestView = document.getElementById('reset-request-view'); +const resetConfirmView = document.getElementById('reset-confirm-view'); +const dashboardView = document.getElementById('dashboard-view'); +const loginForm = document.getElementById('login-form'); +const signupForm = document.getElementById('signup-form'); +const resetRequestForm = document.getElementById('reset-request-form'); +const resetConfirmForm = document.getElementById('reset-confirm-form'); +const loginError = document.getElementById('login-error'); +const signupError = document.getElementById('signup-error'); +const tabBtns = document.querySelectorAll('.tab-btn'); +const userEmail = document.getElementById('user-email'); +const logoutBtn = document.getElementById('logout-btn'); +const modal = document.getElementById('connect-modal'); +const modalBody = document.getElementById('modal-body'); +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 invoicesSection = document.getElementById('invoices-section'); +const adminSection = document.getElementById('admin-section'); +const adminNav = document.getElementById('admin-nav'); + +// Platform config +const PLATFORM_CONFIG = { + tiktok: { + name: 'TikTok', + type: 'oauth', + oauthUrl: 'https://tiktok.squaremcp.com/auth/tiktok/start', + scopes: 'user.info.basic,user.info.profile,user.info.stats,video.list,video.publish', + }, + facebook: { + name: 'Facebook', + type: 'token', + fields: [ + { key: 'accessToken', label: 'Page Access Token', type: 'password' }, + { key: 'pageId', label: 'Page ID', type: 'text' }, + ], + help: 'Get your Page Access Token from the Facebook Graph API Explorer. Run GET /me/accounts and copy the access_token for your page.', + }, + instagram: { + name: 'Instagram', + type: 'token', + fields: [ + { key: 'accessToken', label: 'Instagram Access Token', type: 'password' }, + { key: 'businessAccountId', label: 'Business Account ID', type: 'text' }, + ], + help: 'Your Instagram account must be a Business/Creator account connected to a Facebook Page. Get the token from Graph API Explorer with instagram_basic scope.', + }, + linkedin: { + name: 'LinkedIn', + type: 'token', + fields: [ + { key: 'accessToken', label: 'LinkedIn Access Token', type: 'password' }, + ], + help: 'Generate an access token from the LinkedIn Developer Portal with w_member_social scope.', + }, + twitter: { + name: 'Twitter / X', + type: 'token', + fields: [ + { key: 'accessToken', label: 'Access Token', type: 'password' }, + { key: 'accessTokenSecret', label: 'Access Token Secret', type: 'password' }, + { key: 'apiKey', label: 'API Key', type: 'password' }, + { key: 'apiSecret', label: 'API Secret', type: 'password' }, + ], + help: 'Get all four values from the Twitter Developer Portal under Keys and Tokens.', + }, + telegram: { + name: 'Telegram', + type: 'token', + fields: [ + { key: 'accessToken', label: 'Bot Token', type: 'password' }, + ], + help: 'Message @BotFather on Telegram and create a new bot. Copy the token it gives you.', + }, + discord: { + name: 'Discord', + type: 'token', + fields: [ + { key: 'accessToken', label: 'Bot Token', type: 'password' }, + ], + help: 'Go to discord.com/developers/applications → New Application → Bot → Reset Token.', + }, + whatsapp: { + name: 'WhatsApp', + type: 'token', + fields: [ + { key: 'accessToken', label: 'Access Token', type: 'password' }, + { key: 'phoneNumberId', label: 'Phone Number ID', type: 'text' }, + { key: 'businessAccountId', label: 'Business Account ID', type: 'text' }, + ], + help: 'From Meta Business Settings → WhatsApp → API Setup. You need a verified business phone number.', + }, + email: { + name: 'Email', + type: 'token', + fields: [ + { key: 'host', label: 'IMAP Host', type: 'text' }, + { key: 'port', label: 'IMAP Port', type: 'text' }, + { key: 'user', label: 'Email Address', type: 'email' }, + { key: 'password', label: 'App Password', type: 'password' }, + { key: 'smtpHost', label: 'SMTP Host', type: 'text' }, + { key: 'smtpPort', label: 'SMTP Port', type: 'text' }, + ], + help: 'Use an app-specific password (not your login password). Common hosts: Gmail=smtp.gmail.com, Yahoo=smtp.mail.yahoo.com.', + }, +}; + +// Auth helpers +async function apiPost(path, body) { + const res = await fetch(`${API_BASE}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(body), + }); + return res.json(); +} + +async function apiGet(path) { + const res = await fetch(`${API_BASE}${path}`, { credentials: 'include' }); + return res.json(); +} + +// Views +function hideAllViews() { + loginView.classList.add('hidden'); + resetRequestView.classList.add('hidden'); + resetConfirmView.classList.add('hidden'); + dashboardView.classList.add('hidden'); +} + +function showLogin() { + hideAllViews(); + loginView.classList.remove('hidden'); +} + +function showDashboard() { + hideAllViews(); + dashboardView.classList.remove('hidden'); + userEmail.textContent = currentUser?.email || ''; + updateConnectionStatus(); + updateUsage(); + loadInvoices(); + if (isAdmin) loadAdminPanel(); +} + +// Tab switching +tabBtns.forEach(btn => { + btn.addEventListener('click', () => { + tabBtns.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + const tab = btn.dataset.tab; + if (tab === 'login') { + loginForm.classList.remove('hidden'); + signupForm.classList.add('hidden'); + } else { + loginForm.classList.add('hidden'); + signupForm.classList.remove('hidden'); + } + }); +}); + +// Navigation +navLinks.forEach(link => { + link.addEventListener('click', () => { + navLinks.forEach(l => l.classList.remove('active')); + link.classList.add('active'); + const view = link.dataset.view; + platformGrid.classList.toggle('hidden', view !== 'platforms'); + document.querySelector('.welcome').classList.toggle('hidden', view !== 'platforms'); + document.querySelector('.usage-bar').classList.toggle('hidden', view !== 'platforms'); + invoicesSection.classList.toggle('hidden', view !== 'invoices'); + adminSection.classList.toggle('hidden', view !== 'admin'); + }); +}); + +// Login +loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + loginError.textContent = ''; + const fd = new FormData(loginForm); + const data = await apiPost('/api/auth/login', { + email: fd.get('email'), + password: fd.get('password'), + }); + if (data.error) { + loginError.textContent = data.error; + return; + } + currentUser = data; + isAdmin = data.plan === 'enterprise'; // simplistic admin check + showDashboard(); +}); + +// Signup +signupForm.addEventListener('submit', async (e) => { + e.preventDefault(); + signupError.textContent = ''; + const fd = new FormData(signupForm); + const data = await apiPost('/api/auth/signup', { + email: fd.get('email'), + password: fd.get('password'), + }); + if (data.error) { + signupError.textContent = data.error; + return; + } + currentUser = data; + isAdmin = false; + showDashboard(); +}); + +// Logout +logoutBtn.addEventListener('click', async () => { + await apiPost('/api/auth/logout', {}); + currentUser = null; + isAdmin = false; + showLogin(); +}); + +// Password reset request +resetRequestForm?.addEventListener('submit', async (e) => { + e.preventDefault(); + const errorEl = document.getElementById('reset-request-error'); + const successEl = document.getElementById('reset-request-success'); + errorEl.textContent = ''; + successEl.textContent = ''; + const fd = new FormData(resetRequestForm); + const data = await apiPost('/api/auth/forgot-password', { email: fd.get('email') }); + if (data.resetUrl) { + successEl.textContent = `Reset link: ${data.resetUrl}`; + } else { + successEl.textContent = data.message; + } +}); + +document.getElementById('back-to-login')?.addEventListener('click', (e) => { + e.preventDefault(); + showLogin(); +}); + +// Password reset confirm +resetConfirmForm?.addEventListener('submit', async (e) => { + e.preventDefault(); + const errorEl = document.getElementById('reset-confirm-error'); + const successEl = document.getElementById('reset-confirm-success'); + errorEl.textContent = ''; + successEl.textContent = ''; + const fd = new FormData(resetConfirmForm); + const urlParams = new URLSearchParams(window.location.search); + const token = urlParams.get('token'); + if (!token) { + errorEl.textContent = 'Missing reset token'; + return; + } + const data = await apiPost('/api/auth/reset-password', { + token, + password: fd.get('password'), + }); + if (data.error) { + errorEl.textContent = data.error; + return; + } + successEl.textContent = 'Password updated! Redirecting to login...'; + setTimeout(() => { + window.location.href = '/'; + }, 2000); +}); + +// Add forgot password link to login form +const forgotLink = document.createElement('a'); +forgotLink.href = '#'; +forgotLink.textContent = 'Forgot password?'; +forgotLink.style.cssText = 'color:#888;font-size:13px;text-align:center;display:block;margin-top:8px;'; +forgotLink.addEventListener('click', (e) => { + e.preventDefault(); + hideAllViews(); + resetRequestView.classList.remove('hidden'); +}); +loginForm.appendChild(forgotLink); + +// Connection status +async function updateConnectionStatus() { + try { + const data = await apiGet('/api/connections'); + const connections = data.connections || {}; + 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]) { + badge.textContent = 'Connected'; + badge.classList.remove('disconnected'); + badge.classList.add('connected'); + btn.textContent = 'Manage'; + } else { + badge.textContent = 'Not connected'; + badge.classList.remove('connected'); + badge.classList.add('disconnected'); + btn.textContent = 'Connect'; + } + }); + } catch { + // ignore + } +} + +// Usage +async function updateUsage() { + try { + const data = await apiGet('/api/usage'); + const badge = document.getElementById('plan-badge'); + const text = document.getElementById('usage-text'); + const fill = document.getElementById('usage-bar-fill'); + badge.textContent = data.plan; + if (data.monthlyLimit === -1) { + text.textContent = `${data.used} calls (unlimited)`; + fill.style.width = '5%'; + fill.className = 'usage-bar-fill'; + } else { + text.textContent = `${data.used} / ${data.monthlyLimit} calls this month`; + const pct = Math.min(100, (data.used / data.monthlyLimit) * 100); + fill.style.width = pct + '%'; + fill.className = 'usage-bar-fill' + (pct > 90 ? ' danger' : pct > 70 ? ' warning' : ''); + } + } catch { + // ignore + } +} + +// Invoices +async function loadInvoices() { + try { + const data = await apiGet('/api/invoices'); + const list = document.getElementById('invoices-list'); + const invoices = data.invoices || []; + if (!invoices.length) { + list.innerHTML = '

No invoices yet.

'; + return; + } + list.innerHTML = invoices.map(inv => ` +
+
+
${inv.invoice_number}
+
${inv.period_start} → ${inv.period_end}
+
+
+
$${inv.amount}
+ ${inv.status} +
+
+ `).join(''); + } catch { + document.getElementById('invoices-list').innerHTML = '

Failed to load invoices.

'; + } +} + +// Admin panel +async function loadAdminPanel() { + try { + const data = await apiGet('/api/admin/customers'); + const container = document.getElementById('admin-customers'); + const customers = data.customers || []; + container.innerHTML = ` + + + + ${customers.map(c => ` + + + + + + + + + `).join('')} + +
EmailPlanRoleActiveJoinedActions
${c.email}${c.plan}${c.role}${c.active ? 'Yes' : 'No'}${new Date(c.created_at).toLocaleDateString()}
+ `; + } catch { + document.getElementById('admin-customers').innerHTML = '

Failed to load admin data.

'; + } +} + +window.generateInvoice = async function(customerId) { + try { + const res = await fetch(`${API_BASE}/api/admin/customers/${customerId}/invoice`, { + method: 'POST', + credentials: 'include', + }); + const data = await res.json(); + if (data.error) { + alert(data.error); + return; + } + alert(`Invoice ${data.invoice_number} created for $${data.amount}`); + loadAdminPanel(); + } catch { + alert('Failed to generate invoice'); + } +}; + +// Modal +function openModal(html) { + modalBody.innerHTML = html; + modal.classList.remove('hidden'); +} + +function closeModal() { + modal.classList.add('hidden'); + modalBody.innerHTML = ''; +} + +modalClose.addEventListener('click', closeModal); +modalBackdrop.addEventListener('click', closeModal); + +// Platform connection +function renderConnectForm(platform) { + const cfg = PLATFORM_CONFIG[platform]; + if (!cfg) return ''; + + if (cfg.type === 'oauth') { + const state = btoa(JSON.stringify({ platform, user: currentUser?.id })); + const url = `${cfg.oauthUrl}?state=${encodeURIComponent(state)}&scope=${encodeURIComponent(cfg.scopes)}`; + return ` +
+

Connect ${cfg.name}

+

You'll be redirected to ${cfg.name} to authorize SquareMCP.

+
${cfg.help || ''}
+ Connect with ${cfg.name} +
+ `; + } + + const fieldsHtml = cfg.fields.map(f => ` + + + `).join(''); + + return ` +
+

Connect ${cfg.name}

+

Paste your credentials below. They are encrypted and stored securely.

+ ${fieldsHtml} +
${cfg.help}
+ +

+
+ `; +} + +document.querySelectorAll('.btn-connect').forEach(btn => { + btn.addEventListener('click', () => { + const platform = btn.dataset.platform; + openModal(renderConnectForm(platform)); + + const form = modalBody.querySelector('form.connect-form'); + if (form) { + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const errorEl = document.getElementById('connect-error'); + errorEl.textContent = ''; + errorEl.style.color = '#dc2626'; + const fd = new FormData(form); + const credentials = {}; + for (const [key, value] of fd.entries()) { + credentials[key] = value; + } + + try { + const res = await fetch(`${API_BASE}/api/connect/${platform}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(credentials), + }); + const data = await res.json(); + if (data.error) { + errorEl.textContent = data.error; + return; + } + errorEl.textContent = 'Connected successfully!'; + errorEl.style.color = '#10a37f'; + setTimeout(() => { + closeModal(); + updateConnectionStatus(); + }, 1000); + } catch (err) { + errorEl.textContent = 'Network error. Please try again.'; + } + }); + } + }); +}); + +// Check session on load +async function checkSession() { + // Check for reset token in URL + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('token')) { + hideAllViews(); + resetConfirmView.classList.remove('hidden'); + return; + } + + try { + const data = await apiGet('/api/auth/me'); + if (data.id) { + currentUser = data; + isAdmin = data.plan === 'enterprise'; + if (isAdmin) adminNav.classList.remove('hidden'); + showDashboard(); + } else { + showLogin(); + } + } catch { + showLogin(); + } +} + +// Init +checkSession(); diff --git a/product/app/index.html b/product/app/index.html new file mode 100644 index 0000000..232dfa3 --- /dev/null +++ b/product/app/index.html @@ -0,0 +1,230 @@ + + + + + +SquareMCP — AI Social Gateway + + + +
+ + + + + + + +
+
+ +
+ + +
+
+ + + +

+
+ +
+
+ + + + + + +
+ + + + diff --git a/product/app/nginx-app.conf b/product/app/nginx-app.conf new file mode 100644 index 0000000..ff1ba4c --- /dev/null +++ b/product/app/nginx-app.conf @@ -0,0 +1,15 @@ +server { + listen 8080; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/product/app/styles.css b/product/app/styles.css new file mode 100644 index 0000000..265a2f5 --- /dev/null +++ b/product/app/styles.css @@ -0,0 +1,610 @@ +*, *::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; + --danger: #dc2626; + --radius: 12px; + --shadow: 0 4px 24px rgba(0,0,0,0.4); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + line-height: 1.5; +} + +.hidden { display: none !important; } + +/* Views */ +.view { + min-height: 100vh; +} + +/* Auth */ +#login-view { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.auth-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 40px; + width: 100%; + max-width: 400px; + box-shadow: var(--shadow); +} + +.logo { + text-align: center; + margin-bottom: 32px; +} + +.logo-mark { + width: 56px; + height: 56px; + background: linear-gradient(135deg, #25f4ee, #fe2c55); + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 700; + font-size: 28px; + margin: 0 auto 16px; +} + +.logo-mark.small { + width: 36px; + height: 36px; + font-size: 18px; + border-radius: 10px; + margin: 0; +} + +.logo h1 { + font-size: 24px; + font-weight: 600; + margin-bottom: 4px; +} + +.logo p { + color: var(--text-secondary); + font-size: 14px; +} + +.tabs { + display: flex; + gap: 8px; + margin-bottom: 24px; + background: var(--bg); + padding: 4px; + border-radius: 10px; +} + +.tab-btn { + flex: 1; + padding: 10px; + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.tab-btn.active { + background: var(--surface); + color: var(--text); + box-shadow: 0 1px 3px rgba(0,0,0,0.3); +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.auth-form input { + padding: 12px 14px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + font-size: 15px; + outline: none; + transition: border-color 0.2s; +} + +.auth-form input:focus { + border-color: var(--accent); +} + +.auth-form input::placeholder { + color: #555; +} + +.btn { + padding: 12px 20px; + border: none; + border-radius: 8px; + font-size: 15px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.btn-primary { + background: var(--accent); + color: #fff; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary); + padding: 8px 14px; + font-size: 13px; +} + +.btn-ghost:hover { + color: var(--text); +} + +.btn-connect { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + padding: 8px 16px; + font-size: 13px; + white-space: nowrap; +} + +.btn-connect:hover { + background: var(--surface-hover); + border-color: var(--accent); +} + +.error-msg { + color: var(--danger); + font-size: 13px; + text-align: center; + min-height: 18px; +} + +/* Dashboard */ +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 24px; + background: var(--surface); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} + +.header-left { + display: flex; + align-items: center; + gap: 12px; +} + +.app-title { + font-weight: 600; + font-size: 16px; +} + +.header-right { + display: flex; + align-items: center; + gap: 16px; +} + +.user-email { + color: var(--text-secondary); + font-size: 14px; +} + +.dashboard { + max-width: 1200px; + margin: 0 auto; + padding: 32px 24px; +} + +.welcome { + margin-bottom: 32px; +} + +.welcome h2 { + font-size: 28px; + font-weight: 600; + margin-bottom: 8px; +} + +.welcome p { + color: var(--text-secondary); + font-size: 16px; +} + +.platform-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 16px; +} + +.platform-card { + display: flex; + align-items: center; + gap: 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + transition: border-color 0.2s, transform 0.2s; +} + +.platform-card:hover { + border-color: #3a3a3b; + transform: translateY(-2px); +} + +.platform-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + flex-shrink: 0; +} + +.platform-info { + flex: 1; + min-width: 0; +} + +.platform-info h3 { + font-size: 16px; + font-weight: 600; + margin-bottom: 2px; +} + +.platform-desc { + color: var(--text-secondary); + font-size: 13px; + margin-bottom: 6px; +} + +.status-badge { + display: inline-block; + padding: 3px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.status-badge.connected { + background: rgba(16, 163, 127, 0.15); + color: var(--accent); +} + +.status-badge.disconnected { + background: rgba(136, 136, 136, 0.1); + color: var(--text-secondary); +} + +/* Modal */ +.modal { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0,0,0,0.7); + backdrop-filter: blur(4px); +} + +.modal-content { + position: relative; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 32px; + width: 100%; + max-width: 480px; + max-height: 80vh; + overflow-y: auto; + box-shadow: var(--shadow); + z-index: 1; +} + +.modal-close { + position: absolute; + top: 16px; + right: 16px; + background: none; + border: none; + color: var(--text-secondary); + font-size: 24px; + cursor: pointer; +} + +.modal-close:hover { + color: var(--text); +} + +/* Connection form in modal */ +.connect-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.connect-form h3 { + margin-bottom: 8px; +} + +.connect-form p { + color: var(--text-secondary); + font-size: 14px; + margin-bottom: 8px; +} + +.connect-form label { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + +.connect-form input { + padding: 12px 14px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + font-size: 14px; + outline: none; +} + +.connect-form input:focus { + border-color: var(--accent); +} + +.connect-form .btn-primary { + margin-top: 8px; +} + +.instructions { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.6; +} + +.instructions code { + background: #2a2a2b; + padding: 2px 6px; + border-radius: 4px; + font-family: 'SF Mono', monospace; + font-size: 12px; +} + +@media (max-width: 640px) { + .platform-grid { + grid-template-columns: 1fr; + } + .auth-card { + padding: 28px 20px; + } +} + +/* Usage bar */ +.usage-bar { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px 20px; + margin-bottom: 24px; +} + +.usage-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.plan-badge { + background: var(--accent); + color: #fff; + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.usage-text { + color: var(--text-secondary); + font-size: 13px; +} + +.usage-bar-track { + height: 6px; + background: var(--bg); + border-radius: 3px; + overflow: hidden; +} + +.usage-bar-fill { + height: 100%; + background: var(--accent); + border-radius: 3px; + transition: width 0.5s ease; +} + +.usage-bar-fill.warning { + background: #f59e0b; +} + +.usage-bar-fill.danger { + background: #dc2626; +} + +/* Header nav */ +.header-nav { + display: flex; + gap: 4px; + margin-right: 16px; +} + +.nav-link { + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + padding: 6px 12px; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; +} + +.nav-link:hover { + color: var(--text); +} + +.nav-link.active { + background: var(--bg); + color: var(--text); +} + +/* Invoices */ +.invoices-section, .admin-section { + margin-top: 24px; +} + +.invoices-section h3, .admin-section h3 { + font-size: 18px; + margin-bottom: 16px; +} + +.invoices-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.invoice-item { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px 20px; +} + +.invoice-item .inv-num { + font-weight: 600; + font-size: 14px; +} + +.invoice-item .inv-period { + color: var(--text-secondary); + font-size: 12px; +} + +.invoice-item .inv-amount { + font-weight: 700; + font-size: 16px; +} + +.invoice-item .inv-status { + padding: 3px 10px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; +} + +.inv-status.draft { background: rgba(136,136,136,0.1); color: #888; } +.inv-status.sent { background: rgba(245,158,11,0.15); color: #f59e0b; } +.inv-status.paid { background: rgba(16,163,127,0.15); color: var(--accent); } +.inv-status.overdue { background: rgba(220,38,38,0.15); color: #dc2626; } + +/* Admin */ +.admin-customers { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.admin-table th { + text-align: left; + padding: 12px 16px; + color: var(--text-secondary); + font-weight: 500; + border-bottom: 1px solid var(--border); +} + +.admin-table td { + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.admin-table tr:last-child td { + border-bottom: none; +} + +.admin-table .btn-small { + padding: 4px 10px; + font-size: 11px; +} + +/* Password reset */ +.success-msg { + color: var(--accent); + font-size: 13px; + text-align: center; + min-height: 18px; +} diff --git a/product/site/Dockerfile b/product/site/Dockerfile index fa71092..e8a37e8 100644 --- a/product/site/Dockerfile +++ b/product/site/Dockerfile @@ -6,5 +6,12 @@ COPY product/site/styles.css /usr/share/nginx/html/styles.css COPY product/site/script.js /usr/share/nginx/html/script.js COPY product/site/squaremcp-logo.svg /usr/share/nginx/html/squaremcp-logo.svg COPY product/site/squaremcp-hero-loop.mp4 /usr/share/nginx/html/squaremcp-hero-loop.mp4 +COPY product/site/squaremcp-tiktok-launch.mp4 /usr/share/nginx/html/squaremcp-tiktok-launch.mp4 +COPY product/site/tiktok /usr/share/nginx/html/tiktok +COPY product/site/tiktokkFNJHjzDuzvGIlXnK4MaGw3MSluybOih.txt /usr/share/nginx/html/tiktokkFNJHjzDuzvGIlXnK4MaGw3MSluybOih.txt COPY product/site/privacy.html /usr/share/nginx/html/privacy.html +COPY product/site/privacy /usr/share/nginx/html/privacy COPY product/site/terms.html /usr/share/nginx/html/terms.html +COPY product/site/terms /usr/share/nginx/html/terms +COPY product/site/tiktok/tiktokIIxGZsCAoYNJVObSA8cYj8FyWrAu1nIW.txt /usr/share/nginx/html/tiktokIIxGZsCAoYNJVObSA8cYj8FyWrAu1nIW.txt +COPY product/site/tiktok/tiktokwJMUoUxgRBFO9T8p08qlMgtsez0Ktr9y.txt /usr/share/nginx/html/tiktokwJMUoUxgRBFO9T8p08qlMgtsez0Ktr9y.txt diff --git a/product/site/nginx-site.conf b/product/site/nginx-site.conf index 558d245..42f2149 100644 --- a/product/site/nginx-site.conf +++ b/product/site/nginx-site.conf @@ -1,6 +1,6 @@ server { listen 8080; - server_name squaremcp.com www.squaremcp.com; + server_name squaremcp.com www.squaremcp.com tiktok.squaremcp.com; root /usr/share/nginx/html; index index.html; diff --git a/product/site/privacy/tiktokXbaTJRvDkwUNzhEXou9SsogGyiDkQshF.txt b/product/site/privacy/tiktokXbaTJRvDkwUNzhEXou9SsogGyiDkQshF.txt new file mode 100644 index 0000000..e5e2170 --- /dev/null +++ b/product/site/privacy/tiktokXbaTJRvDkwUNzhEXou9SsogGyiDkQshF.txt @@ -0,0 +1 @@ +tiktok-developers-site-verification=XbaTJRvDkwUNzhEXou9SsogGyiDkQshF diff --git a/product/site/squaremcp-certificate.yaml b/product/site/squaremcp-certificate.yaml index dec385d..9c7b631 100644 --- a/product/site/squaremcp-certificate.yaml +++ b/product/site/squaremcp-certificate.yaml @@ -11,3 +11,4 @@ spec: dnsNames: - squaremcp.com - www.squaremcp.com + - tiktok.squaremcp.com diff --git a/product/site/squaremcp-k8s-ingress.yaml b/product/site/squaremcp-k8s-ingress.yaml index 084dc92..1bdbddf 100644 --- a/product/site/squaremcp-k8s-ingress.yaml +++ b/product/site/squaremcp-k8s-ingress.yaml @@ -15,7 +15,7 @@ spec: spec: containers: - name: squaremcp-site - image: localhost:32000/squaremcp-site:latest + image: localhost:32000/squaremcp-site@sha256:395e736f1899ce0f2402e34caa95359e2eb54b5424318cf8139982e66b35a974 imagePullPolicy: Always ports: - containerPort: 8080 @@ -90,8 +90,33 @@ spec: name: squaremcp-site port: number: 80 + - host: tiktok.squaremcp.com + http: + paths: + - path: /auth/tiktok + pathType: Prefix + backend: + service: + name: hermes-mcp + port: + number: 3456 + - path: /api/pilot-request + pathType: Prefix + backend: + service: + name: hermes-mcp + port: + number: 3456 + - path: / + pathType: Prefix + backend: + service: + name: squaremcp-site + port: + number: 80 tls: - hosts: - squaremcp.com - www.squaremcp.com + - tiktok.squaremcp.com secretName: squaremcp-tls diff --git a/product/site/squaremcp-tiktok-launch.mp4 b/product/site/squaremcp-tiktok-launch.mp4 new file mode 100644 index 0000000..f48548f Binary files /dev/null and b/product/site/squaremcp-tiktok-launch.mp4 differ diff --git a/product/site/terms/tiktokJLZJ5wt0TEQdq2RTHGAZtY9ofrtPFBeM.txt b/product/site/terms/tiktokJLZJ5wt0TEQdq2RTHGAZtY9ofrtPFBeM.txt new file mode 100644 index 0000000..9817b46 --- /dev/null +++ b/product/site/terms/tiktokJLZJ5wt0TEQdq2RTHGAZtY9ofrtPFBeM.txt @@ -0,0 +1 @@ +tiktok-developers-site-verification=JLZJ5wt0TEQdq2RTHGAZtY9ofrtPFBeM diff --git a/product/site/tiktok-demo-video.mp4 b/product/site/tiktok-demo-video.mp4 new file mode 100644 index 0000000..63b6c2f Binary files /dev/null and b/product/site/tiktok-demo-video.mp4 differ diff --git a/product/site/tiktok-demo.html b/product/site/tiktok-demo.html new file mode 100644 index 0000000..ba63d52 --- /dev/null +++ b/product/site/tiktok-demo.html @@ -0,0 +1,414 @@ + + + + + +SquareMCP - TikTok Integration Demo + + + + +
+
S
+

SquareMCP

+ TikTok Integration Demo +
+ +
+ + + + + diff --git a/product/site/tiktok/tiktokIIxGZsCAoYNJVObSA8cYj8FyWrAu1nIW.txt b/product/site/tiktok/tiktokIIxGZsCAoYNJVObSA8cYj8FyWrAu1nIW.txt new file mode 100644 index 0000000..41f2fcf --- /dev/null +++ b/product/site/tiktok/tiktokIIxGZsCAoYNJVObSA8cYj8FyWrAu1nIW.txt @@ -0,0 +1 @@ +tiktok-developers-site-verification=IIxGZsCAoYNJVObSA8cYj8FyWrAu1nIW \ No newline at end of file diff --git a/product/site/tiktok/tiktokebJaRSVgFOD0hQE1YXb5XhZ3IvKw4LVn.txt b/product/site/tiktok/tiktokebJaRSVgFOD0hQE1YXb5XhZ3IvKw4LVn.txt new file mode 100644 index 0000000..00e3712 --- /dev/null +++ b/product/site/tiktok/tiktokebJaRSVgFOD0hQE1YXb5XhZ3IvKw4LVn.txt @@ -0,0 +1 @@ +tiktok-developers-site-verification=ebJaRSVgFOD0hQE1YXb5XhZ3IvKw4LVn \ No newline at end of file diff --git a/product/site/tiktok/tiktokwJMUoUxgRBFO9T8p08qlMgtsez0Ktr9y.txt b/product/site/tiktok/tiktokwJMUoUxgRBFO9T8p08qlMgtsez0Ktr9y.txt new file mode 100644 index 0000000..00cc6bc --- /dev/null +++ b/product/site/tiktok/tiktokwJMUoUxgRBFO9T8p08qlMgtsez0Ktr9y.txt @@ -0,0 +1 @@ +tiktok-developers-site-verification=wJMUoUxgRBFO9T8p08qlMgtsez0Ktr9y \ No newline at end of file diff --git a/product/site/tiktokkFNJHjzDuzvGIlXnK4MaGw3MSluybOih.txt b/product/site/tiktokkFNJHjzDuzvGIlXnK4MaGw3MSluybOih.txt new file mode 100644 index 0000000..1655424 --- /dev/null +++ b/product/site/tiktokkFNJHjzDuzvGIlXnK4MaGw3MSluybOih.txt @@ -0,0 +1 @@ +tiktok-developers-site-verification=kFNJHjzDuzvGIlXnK4MaGw3MSluybOih diff --git a/scripts/record-tiktok-demo.cjs b/scripts/record-tiktok-demo.cjs new file mode 100644 index 0000000..9a29398 --- /dev/null +++ b/scripts/record-tiktok-demo.cjs @@ -0,0 +1,52 @@ +const { chromium } = require('playwright'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const PORT = 9876; +const DEMO_HTML = path.join(__dirname, '../product/site/tiktok-demo.html'); + +// Simple static server +const server = http.createServer((req, res) => { + const filePath = req.url === '/' ? DEMO_HTML : path.join(__dirname, '../product/site', req.url); + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); res.end('Not found'); return; + } + const ext = path.extname(filePath); + const ct = ext === '.html' ? 'text/html' : ext === '.js' ? 'application/javascript' : ext === '.css' ? 'text/css' : 'application/octet-stream'; + res.writeHead(200, { 'Content-Type': ct }); + res.end(data); + }); +}); + +async function record() { + server.listen(PORT, '127.0.0.1', async () => { + console.log(`Server running on http://127.0.0.1:${PORT}`); + + const outDir = path.join(__dirname, '../product/site'); + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + recordVideo: { dir: outDir, size: { width: 1280, height: 800 } } + }); + const page = await context.newPage(); + + await page.goto(`http://127.0.0.1:${PORT}/tiktok-demo.html`, { waitUntil: 'networkidle' }); + console.log('Page loaded, recording demo...'); + + // Wait for the full demo to complete (~2.5 minutes) + await page.waitForTimeout(155000); + + await context.close(); + await browser.close(); + server.close(); + console.log('Recording complete'); + }); +} + +record().catch(err => { + console.error(err); + server.close(); + process.exit(1); +}); diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..8e91a1c --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,98 @@ +import bcryptjs from 'bcryptjs'; +const { hash, compare } = bcryptjs; +import jwt from 'jsonwebtoken'; +const { sign, verify } = jwt; +import { getPool } from './db.js'; +import type { RowDataPacket } from 'mysql2'; + +const JWT_SECRET = process.env.JWT_SECRET ?? process.env.CREDENTIAL_ENCRYPTION_KEY ?? 'dev-secret-change-me'; +const SALT_ROUNDS = 12; + +export interface JWTPayload { + sub: string; // customer id + email: string; + plan: string; +} + +export async function hashPassword(password: string): Promise { + return hash(password, SALT_ROUNDS); +} + +export async function verifyPassword(password: string, hash: string): Promise { + return compare(password, hash); +} + +export function signJWT(payload: JWTPayload): string { + return sign(payload, JWT_SECRET, { expiresIn: '7d' }); +} + +export function verifyJWT(token: string): JWTPayload { + return verify(token, JWT_SECRET) as JWTPayload; +} + +interface CustomerRow extends RowDataPacket { + id: string; + email: string; + plan: string; + active: boolean; + api_key: string; + password_hash: string | null; +} + +export async function findCustomerByEmail(email: string): Promise { + const [rows] = await getPool().query( + 'SELECT id, email, plan, active, api_key, password_hash FROM customers WHERE email = ?', + [email] + ); + return rows[0] ?? null; +} + +export async function findCustomerById(id: string): Promise { + const [rows] = await getPool().query( + 'SELECT id, email, plan, active, api_key, password_hash FROM customers WHERE id = ?', + [id] + ); + return rows[0] ?? null; +} + +export async function createCustomer( + id: string, + email: string, + passwordHash: string, + apiKey: string +): Promise { + await getPool().query( + 'INSERT INTO customers (id, email, password_hash, api_key, plan, active) VALUES (?, ?, ?, ?, ?, ?)', + [id, email, passwordHash, apiKey, 'free', true] + ); +} + +export async function setResetToken(email: string, token: string): Promise { + const [result] = await getPool().query( + 'UPDATE customers SET reset_token = ?, reset_expires_at = DATE_ADD(NOW(), INTERVAL 1 HOUR) WHERE email = ?', + [token, email] + ); + return result.affectedRows > 0; +} + +export async function findCustomerByResetToken(token: string) { + const [rows] = await getPool().query( + 'SELECT id, email, plan, active, api_key, password_hash FROM customers WHERE reset_token = ? AND reset_expires_at > NOW()', + [token] + ); + return rows[0] ?? null; +} + +export async function clearResetToken(customerId: string): Promise { + await getPool().query( + 'UPDATE customers SET reset_token = NULL, reset_expires_at = NULL WHERE id = ?', + [customerId] + ); +} + +export async function updatePassword(customerId: string, passwordHash: string): Promise { + await getPool().query( + 'UPDATE customers SET password_hash = ? WHERE id = ?', + [passwordHash, customerId] + ); +} diff --git a/src/billing/invoices.ts b/src/billing/invoices.ts new file mode 100644 index 0000000..8de1a09 --- /dev/null +++ b/src/billing/invoices.ts @@ -0,0 +1,122 @@ +import { getPool } from '../db.js'; +import type { RowDataPacket } from 'mysql2'; + +export interface InvoiceLineItem { + description: string; + quantity: number; + unit_price: number; + amount: number; +} + +export interface Invoice extends RowDataPacket { + id: number; + customer_id: string; + invoice_number: string; + amount: number; + currency: string; + status: 'draft' | 'sent' | 'paid' | 'overdue'; + period_start: string; + period_end: string; + line_items: InvoiceLineItem[]; + sent_at: string | null; + paid_at: string | null; + created_at: string; +} + +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}`; +} + +export async function createInvoice( + customerId: string, + amount: number, + lineItems: InvoiceLineItem[], + periodStart: string, + 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] + ); + const [rows] = await getPool().query( + 'SELECT * FROM invoices WHERE invoice_number = ?', + [invoiceNumber] + ); + return rows[0]; +} + +export async function getCustomerInvoices(customerId: string): Promise { + const [rows] = await getPool().query( + 'SELECT * FROM invoices WHERE customer_id = ? ORDER BY created_at DESC', + [customerId] + ); + return rows; +} + +export async function getInvoiceByNumber(invoiceNumber: string): Promise { + const [rows] = await getPool().query( + 'SELECT * FROM invoices WHERE invoice_number = ?', + [invoiceNumber] + ); + return rows[0] ?? null; +} + +export async function markInvoiceSent(invoiceNumber: string): Promise { + await getPool().query( + "UPDATE invoices SET status = 'sent', sent_at = NOW() WHERE invoice_number = ?", + [invoiceNumber] + ); +} + +export async function markInvoicePaid(invoiceNumber: string): Promise { + await getPool().query( + "UPDATE invoices SET status = 'paid', paid_at = NOW() WHERE invoice_number = ?", + [invoiceNumber] + ); +} + +export async function generateMonthlyInvoice(customerId: string): Promise { + 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') + GROUP BY platform`, + [customerId] + ); + + if (!usageRows.length) return null; + + const lineItems: InvoiceLineItem[] = []; + let total = 0; + + for (const row of usageRows) { + const qty = row.count; + const unitPrice = 0.05; // $0.05 per action + const amount = qty * unitPrice; + total += amount; + lineItems.push({ + description: `${row.platform} actions`, + quantity: qty, + unit_price: unitPrice, + amount: parseFloat(amount.toFixed(2)), + }); + } + + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth(), 1); + const end = new Date(now.getFullYear(), now.getMonth() + 1, 0); + + return createInvoice( + customerId, + parseFloat(total.toFixed(2)), + lineItems, + start.toISOString().slice(0, 10), + end.toISOString().slice(0, 10) + ); +} diff --git a/src/billing/middleware.ts b/src/billing/middleware.ts index 3e02fa1..0738043 100644 --- a/src/billing/middleware.ts +++ b/src/billing/middleware.ts @@ -4,6 +4,7 @@ import { getPool } from '../db.js'; import { getCredential, Platform, PlatformCredentials } from '../multitenancy/credential-store.js'; import type { PlanKey } from './plans.js'; import type { Request, Response, NextFunction } from 'express'; +import { verifyJWT } from '../auth.js'; const redis = createClient({ url: process.env.REDIS_URL }); redis.connect().catch((err) => console.error('[billing] Redis connect error:', err)); @@ -24,16 +25,22 @@ interface CustomerRow extends RowDataPacket { email: string; } -async function resolveCustomer(apiKey: string): Promise { +function buildCustomer(row: CustomerRow): Customer { + return { + id: row.id, + plan: row.plan, + active: Boolean(row.active), + email: row.email, + getCredential: (platform: Platform) => + getCredential(row.id, platform), + }; +} + +export async function resolveCustomerByApiKey(apiKey: string): Promise { const cached = await redis.get(`customer:apikey:${apiKey}`); if (cached) { - const base = JSON.parse(cached) as Omit; - // Re-attach the credential loader (functions can't be cached) - return { - ...base, - getCredential: (platform: Platform) => - getCredential(base.id, platform), - }; + const base = JSON.parse(cached) as Omit; + return buildCustomer(base as CustomerRow); } const [rows] = await getPool().query( @@ -42,50 +49,80 @@ async function resolveCustomer(apiKey: string): Promise { ); if (!rows.length) return null; - const { id, plan, active, email } = rows[0]; - const customer: Customer = { - id, - plan, - active: Boolean(active), - email, - getCredential: (platform: Platform) => - getCredential(id, platform), - }; - - // Cache only the serialisable fields (not the function) - await redis.setEx(`customer:apikey:${apiKey}`, 60, JSON.stringify({ id, plan, active, email })); - return customer; + const row = rows[0]; + await redis.setEx(`customer:apikey:${apiKey}`, 60, JSON.stringify(row)); + return buildCustomer(row); } -// Express middleware: resolve API key → Customer and attach to req.customer +export async function resolveCustomerById(id: string): Promise { + const cached = await redis.get(`customer:id:${id}`); + if (cached) { + const base = JSON.parse(cached) as CustomerRow; + return buildCustomer(base); + } + + const [rows] = await getPool().query( + 'SELECT id, plan, active, email FROM customers WHERE id = ?', + [id] + ); + if (!rows.length) return null; + + const row = rows[0]; + await redis.setEx(`customer:id:${id}`, 60, JSON.stringify(row)); + return buildCustomer(row); +} + +// Express middleware: resolve API key OR JWT cookie → Customer and attach to req.customer export async function meterMiddleware( req: Request, res: Response, next: NextFunction ): Promise { try { + // 1. Try API key const apiKey = (req.headers['x-api-key'] as string | undefined) || (req.query.key as string | undefined); - if (!apiKey) { - res.status(401).json({ error: 'Missing API key' }); + if (apiKey) { + const customer = await resolveCustomerByApiKey(apiKey); + if (!customer) { + res.status(401).json({ error: 'Invalid API key' }); + return; + } + if (!customer.active) { + res.status(403).json({ error: 'Account suspended' }); + return; + } + (req as Request & { customer: Customer }).customer = customer; + next(); return; } - const customer = await resolveCustomer(apiKey); - if (!customer) { - res.status(401).json({ error: 'Invalid API key' }); - return; + // 2. Try JWT session cookie + const jwtCookie = req.cookies?.session; + if (jwtCookie) { + try { + const payload = verifyJWT(jwtCookie); + const customer = await resolveCustomerById(payload.sub); + if (!customer) { + res.status(401).json({ error: 'Invalid session' }); + return; + } + if (!customer.active) { + res.status(403).json({ error: 'Account suspended' }); + return; + } + (req as Request & { customer: Customer }).customer = customer; + next(); + return; + } catch { + res.status(401).json({ error: 'Invalid or expired session' }); + return; + } } - if (!customer.active) { - res.status(403).json({ error: 'Account suspended' }); - return; - } - - (req as Request & { customer: Customer }).customer = customer; - next(); + res.status(401).json({ error: 'Missing API key or session' }); } catch (err) { next(err); } diff --git a/src/billing/usage.ts b/src/billing/usage.ts new file mode 100644 index 0000000..8109034 --- /dev/null +++ b/src/billing/usage.ts @@ -0,0 +1,48 @@ +import { getPool } from '../db.js'; +import { PLANS, type PlanKey } from './plans.js'; + +export async function recordUsage( + customerId: string, + platform: string, + action: string +): Promise { + await getPool().query( + 'INSERT INTO usage_logs (customer_id, platform, action) VALUES (?, ?, ?)', + [customerId, platform, action] + ); +} + +export async function getMonthlyUsage(customerId: string): Promise { + const [rows] = await getPool().query( + `SELECT 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')`, + [customerId] + ); + return rows[0]?.count ?? 0; +} + +export async function getUsageBreakdown(customerId: string): Promise> { + const [rows] = await getPool().query( + `SELECT platform, COUNT(*) as count FROM usage_logs + WHERE customer_id = ? + AND created_at >= DATE_FORMAT(NOW(), '%Y-%m-01') + GROUP BY platform`, + [customerId] + ); + const breakdown: Record = {}; + for (const row of rows) { + breakdown[row.platform] = row.count; + } + return breakdown; +} + +export async function checkLimit(customerId: string, plan: PlanKey): Promise<{ allowed: boolean; limit: number; used: number }> { + const planConfig = PLANS[plan]; + if (planConfig.monthlyCallLimit === -1) { + return { allowed: true, limit: -1, used: 0 }; + } + const used = await getMonthlyUsage(customerId); + return { allowed: used < planConfig.monthlyCallLimit, limit: planConfig.monthlyCallLimit, used }; +} diff --git a/src/clients/facebook.ts b/src/clients/facebook.ts index e2e5427..e9458c0 100644 --- a/src/clients/facebook.ts +++ b/src/clients/facebook.ts @@ -99,7 +99,7 @@ export async function getPosts( const { accessToken, pageId } = await resolveCreds(args, customer); const limit = args.limit ?? 10; const data = await fbRequest( - `/${pageId}/feed?fields=id,message,story,created_time,permalink_url&limit=${limit}`, + `/${pageId}/published_posts?fields=id,message,story,created_time,permalink_url&limit=${limit}`, accessToken ); return (data.data ?? []).map((p: Record) => ({ diff --git a/src/clients/tiktok.ts b/src/clients/tiktok.ts index 20e7b21..41f7f6a 100644 --- a/src/clients/tiktok.ts +++ b/src/clients/tiktok.ts @@ -6,7 +6,7 @@ const TIKTOK_API_BASE = 'https://open.tiktokapis.com/v2'; function getEnvToken(account: string): string { const envKey = `TIKTOK_${account.toUpperCase()}_ACCESS_TOKEN`; - return process.env[envKey] ?? ''; + return process.env[envKey] ?? process.env.TIKTOK_DEFAULT_ACCESS_TOKEN ?? ''; } async function resolveToken(args: { account?: string }, customer?: Customer): Promise { @@ -122,9 +122,13 @@ export async function createVideo( // Step 1: query creator info to get valid privacy levels (sandbox may not support PUBLIC_TO_EVERYONE) const creatorInfo = await getCreatorInfo(args, customer); - const privacyLevel = creatorInfo.privacy_level_options.includes('PUBLIC_TO_EVERYONE') + const options = creatorInfo.privacy_level_options; + // Unaudited apps MUST post SELF_ONLY; prefer that when public isn't available + const privacyLevel = options.includes('PUBLIC_TO_EVERYONE') ? 'PUBLIC_TO_EVERYONE' - : creatorInfo.privacy_level_options[0] ?? 'SELF_ONLY'; + : options.includes('SELF_ONLY') + ? 'SELF_ONLY' + : options[0] ?? 'SELF_ONLY'; // Step 2: initialise upload const init = await tiktokRequest('/post/publish/video/init/', accessToken, 'POST', { diff --git a/src/db.ts b/src/db.ts index 3c70244..bad08c4 100644 --- a/src/db.ts +++ b/src/db.ts @@ -93,6 +93,7 @@ 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, 'customers', 'password_hash', 'VARCHAR(255) NULL'); await db.execute(` CREATE TABLE IF NOT EXISTS oauth_tokens ( @@ -112,10 +113,51 @@ export async function initDatabase(): Promise { plan ENUM('free', 'starter', 'growth', 'enterprise') DEFAULT 'free', active BOOLEAN DEFAULT TRUE, email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NULL, + role ENUM('user', 'admin') DEFAULT 'user', + reset_token VARCHAR(255) NULL, + reset_expires_at TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - INDEX idx_api_key (api_key) + INDEX idx_api_key (api_key), + INDEX idx_email (email), + INDEX idx_reset_token (reset_token) ) `); + + await db.execute(` + CREATE TABLE IF NOT EXISTS usage_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + customer_id VARCHAR(255) NOT NULL, + platform VARCHAR(32) NOT NULL, + action VARCHAR(64) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_customer_time (customer_id, created_at) + ) + `); + + await db.execute(` + CREATE TABLE IF NOT EXISTS invoices ( + id INT AUTO_INCREMENT PRIMARY KEY, + customer_id VARCHAR(255) NOT NULL, + invoice_number VARCHAR(64) NOT NULL UNIQUE, + amount DECIMAL(10,2) NOT NULL, + currency VARCHAR(3) DEFAULT 'USD', + status ENUM('draft', 'sent', 'paid', 'overdue') DEFAULT 'draft', + period_start DATE NOT NULL, + period_end DATE NOT NULL, + line_items JSON, + sent_at TIMESTAMP NULL, + paid_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_customer (customer_id), + INDEX idx_status (status) + ) + `); + + // Ensure new columns on existing customers table + 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'); } finally { db.release(); } diff --git a/src/index.ts b/src/index.ts index 11e2234..898cd13 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import 'dotenv/config'; import express from 'express'; import cors from 'cors'; +import cookieParser from 'cookie-parser'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; @@ -14,7 +15,7 @@ import { tools, handleToolCall } 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'; -import { meterMiddleware, type Customer } from './billing/middleware.js'; +import { meterMiddleware, resolveCustomerByApiKey, resolveCustomerById, type Customer } from './billing/middleware.js'; import { registerClient, getClient, @@ -23,9 +24,13 @@ import { validateAccessToken, getAuthorizeHtml, } from './oauth.js'; -import { initDatabase } from './db.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'; const app = express(); +app.use(cookieParser()); app.use(cors({ origin: '*', methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], @@ -152,14 +157,14 @@ async function appendPilotRequestToVault(requestId: string, body: PilotRequestBo const content = formatPilotRequestMarkdown(requestId, body, req); const dailyNotePath = `Daily Notes/${getEasternDateString()}.md`; - await handleToolCall('obsidian_append_to_note', { + await callTool(req, 'obsidian_append_to_note', { path: 'SquareMCP/Pilot Requests.md', header: 'Pilot Requests', content, create_if_missing: true, }); - await handleToolCall('obsidian_append_to_note', { + await callTool(req, 'obsidian_append_to_note', { path: dailyNotePath, header: 'SquareMCP Pilot Requests', content, @@ -302,19 +307,48 @@ async function requireAuth(req: express.Request, res: express.Response, next: ex // No API key configured = open access if (!API_KEY) return next(); - // 1. Check x-api-key header or query param (backward compatibility) + // 1. Check x-api-key header or query param (backward compatibility — global key) const apiKeyProvided = (req.headers['x-api-key'] as string | undefined) || (req.query.key as string | undefined); if (apiKeyProvided === API_KEY) return next(); - // 2. Check OAuth Bearer token + // 2. Check customer API key (per-user SaaS auth) + if (apiKeyProvided) { + const customer = await resolveCustomerByApiKey(apiKeyProvided); + if (customer && customer.active) { + (req as express.Request & { customer?: Customer }).customer = customer; + return next(); + } + } + + // 3. Check OAuth Bearer token const bearerToken = extractBearerToken(req); if (bearerToken && await validateAccessToken(bearerToken)) return next(); + // 4. Check JWT session cookie (web app auth) + const jwtCookie = req.cookies?.session; + if (jwtCookie) { + try { + const payload = verifyJWT(jwtCookie); + const customer = await resolveCustomerById(payload.sub); + if (customer && customer.active) { + (req as express.Request & { customer?: Customer; jwtUser?: { id: string; email: string; plan: string } }).customer = customer; + (req as express.Request & { jwtUser?: { id: string; email: string; plan: string } }).jwtUser = { + id: payload.sub, + email: payload.email, + plan: payload.plan, + }; + return next(); + } + } catch { + // invalid JWT, fall through to 401 + } + } + res.setHeader( 'WWW-Authenticate', `Bearer realm="hermes", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"` ); - res.status(401).json({ error: 'Unauthorized — provide x-api-key header, ?key= query param, or Bearer token' }); + res.status(401).json({ error: 'Unauthorized — provide x-api-key header, ?key= query param, Bearer token, or session cookie' }); } catch (err) { next(err); } @@ -717,7 +751,7 @@ app.post('/hermes-mcp/:linkSegment/:toolName', requireAuth, async (req, res) => const args = (req.body ?? {}) as Record; console.log(`[chatgpt-mcp] ${toolName}`, JSON.stringify(args).substring(0, 200)); try { - const result = await handleToolCall(toolName, args); + const result = await callTool(req, toolName, args); const text = result.content[0].text; if (text.startsWith('Error:')) { res.status(400).json({ error: text.slice(7).trim() }); @@ -734,7 +768,7 @@ app.get('/hermes-mcp/:linkSegment/:toolName', requireAuth, async (req, res) => { const args = req.query as Record; console.log(`[chatgpt-mcp] GET ${toolName}`, JSON.stringify(args).substring(0, 200)); try { - const result = await handleToolCall(toolName, args); + const result = await callTool(req, toolName, args); const text = result.content[0].text; if (text.startsWith('Error:')) { res.status(400).json({ error: text.slice(7).trim() }); @@ -799,7 +833,7 @@ app.get('/api/obsidian/search', requireAuth, async (req, res) => { const tagsRaw = req.query.tags as string | undefined; const tags = tagsRaw ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean) : undefined; try { - const result = await handleToolCall('obsidian_search_notes', { query, limit, path_filter, tags }); + const result = await callTool(req, 'obsidian_search_notes', { query, limit, path_filter, tags }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -810,7 +844,7 @@ app.get('/api/obsidian/note', requireAuth, async (req, res) => { const path = req.query.path as string | undefined; if (!path) { res.status(400).json({ error: 'path is required' }); return; } try { - const result = await handleToolCall('obsidian_read_note', { path }); + const result = await callTool(req, 'obsidian_read_note', { path }); res.json(parseToolResult(result)); } catch (err) { const msg = (err as Error).message; @@ -822,7 +856,7 @@ app.post('/api/obsidian/note/append', requireAuth, async (req, res) => { const { path, content, header, create_if_missing } = req.body as Record; if (!path || !content) { res.status(400).json({ error: 'path and content are required' }); return; } try { - const result = await handleToolCall('obsidian_append_to_note', { path, content, header, create_if_missing }); + const result = await callTool(req, 'obsidian_append_to_note', { path, content, header, create_if_missing }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -833,16 +867,16 @@ app.put('/api/obsidian/note', requireAuth, async (req, res) => { const { path, content } = req.body as Record; if (!path || !content) { res.status(400).json({ error: 'path and content are required' }); return; } try { - const result = await handleToolCall('obsidian_update_note', { path, content }); + const result = await callTool(req, 'obsidian_update_note', { path, content }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); } }); -app.get('/api/obsidian/sync', requireAuth, async (_req, res) => { +app.get('/api/obsidian/sync', requireAuth, async (req, res) => { try { - const result = await handleToolCall('obsidian_sync_status', {}); + const result = await callTool(req, 'obsidian_sync_status', {}); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -854,7 +888,7 @@ app.post('/api/whatsapp/send', requireAuth, async (req, res) => { const { to, message, account } = req.body as Record; if (!to || !message) { res.status(400).json({ error: 'to and message are required' }); return; } try { - const result = await handleToolCall('whatsapp_send_message', { to, message, account }); + const result = await callTool(req, 'whatsapp_send_message', { to, message, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -865,7 +899,7 @@ app.post('/api/whatsapp/template', requireAuth, async (req, res) => { const { to, template_name, language, components, account } = req.body as Record; if (!to || !template_name) { res.status(400).json({ error: 'to and template_name are required' }); return; } try { - const result = await handleToolCall('whatsapp_send_template', { to, template_name, language, components, account }); + const result = await callTool(req, 'whatsapp_send_template', { to, template_name, language, components, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -875,7 +909,7 @@ app.post('/api/whatsapp/template', requireAuth, async (req, res) => { app.get('/api/whatsapp/templates', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; try { - const result = await handleToolCall('whatsapp_list_templates', { account }); + const result = await callTool(req, 'whatsapp_list_templates', { account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -916,6 +950,93 @@ app.post('/webhook/whatsapp', express.json(), async (req, res) => { } }); +// ── Auth endpoints ────────────────────────────────────────────── + +app.post('/api/auth/signup', express.json(), async (req, res) => { + const { email, password } = req.body as Record; + if (!email || !password) { + res.status(400).json({ error: 'Email and password required' }); + return; + } + if (password.length < 8) { + res.status(400).json({ error: 'Password must be at least 8 characters' }); + return; + } + + const existing = await findCustomerByEmail(email); + if (existing) { + res.status(409).json({ error: 'Email already registered' }); + return; + } + + const id = crypto.randomUUID(); + const apiKey = crypto.randomUUID().replace(/-/g, ''); + const passwordHash = await hashPassword(password); + + try { + // Check if this is the first user — make them admin + const [countRows] = await getPool().query('SELECT COUNT(*) as c FROM customers'); + const isFirstUser = countRows[0]?.c === 0; + await createCustomer(id, email, passwordHash, apiKey); + if (isFirstUser) { + await getPool().query("UPDATE customers SET role = 'admin', plan = 'enterprise' WHERE id = ?", [id]); + } + const token = signJWT({ sub: id, email, plan: isFirstUser ? 'enterprise' : 'free' }); + res.cookie('session', token, { + httpOnly: true, + secure: true, + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + }); + res.status(201).json({ id, email, plan: 'free', api_key: apiKey }); + } catch (err) { + res.status(500).json({ error: 'Failed to create account' }); + } +}); + +app.post('/api/auth/login', express.json(), async (req, res) => { + const { email, password } = req.body as Record; + if (!email || !password) { + res.status(400).json({ error: 'Email and password required' }); + return; + } + + const customer = await findCustomerByEmail(email); + if (!customer || !customer.password_hash) { + res.status(401).json({ error: 'Invalid credentials' }); + return; + } + + const valid = await verifyPassword(password, customer.password_hash); + if (!valid) { + res.status(401).json({ error: 'Invalid credentials' }); + return; + } + + const token = signJWT({ sub: customer.id, email: customer.email, plan: customer.plan }); + res.cookie('session', token, { + httpOnly: true, + secure: true, + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + res.json({ id: customer.id, email: customer.email, plan: customer.plan, api_key: customer.api_key }); +}); + +app.post('/api/auth/logout', (_req, res) => { + res.clearCookie('session'); + res.json({ success: true }); +}); + +app.get('/api/auth/me', requireAuth, async (req, res) => { + const jwtUser = (req as express.Request & { jwtUser?: { id: string; email: string; plan: string } }).jwtUser; + if (jwtUser) { + res.json(jwtUser); + return; + } + res.status(401).json({ error: 'Not authenticated' }); +}); + // ── Customer onboarding endpoints ─────────────────────────────── // Connect WhatsApp — called after customer enters their Meta credentials @@ -1013,11 +1134,174 @@ app.get('/api/connections', meterMiddleware, async (req, res) => { res.json({ customerId: customer.id, connections: status }); }); +// ── Usage & Limits ────────────────────────────────────────────── + +app.get('/api/usage', meterMiddleware, async (req, res) => { + const customer = (req as unknown as { customer: Customer }).customer; + const used = await getMonthlyUsage(customer.id); + const breakdown = await getUsageBreakdown(customer.id); + const limitCheck = await checkLimit(customer.id, customer.plan); + res.json({ + plan: customer.plan, + monthlyLimit: limitCheck.limit, + used, + remaining: limitCheck.limit === -1 ? -1 : Math.max(0, limitCheck.limit - used), + breakdown, + }); +}); + +// ── Invoices ──────────────────────────────────────────────────── + +app.get('/api/invoices', meterMiddleware, async (req, res) => { + const customer = (req as unknown as { customer: Customer }).customer; + const invoices = await getCustomerInvoices(customer.id); + res.json({ invoices }); +}); + +app.get('/api/invoices/:number', meterMiddleware, async (req, res) => { + const invoice = await getInvoiceByNumber(req.params.number); + if (!invoice) { + res.status(404).json({ error: 'Invoice not found' }); + return; + } + res.json(invoice); +}); + +// ── Password Reset ────────────────────────────────────────────── + +app.post('/api/auth/forgot-password', express.json(), async (req, res) => { + const { email } = req.body as Record; + if (!email) { + res.status(400).json({ error: 'Email required' }); + return; + } + + const token = crypto.randomUUID().replace(/-/g, ''); + const success = await setResetToken(email, token); + + if (!success) { + // Don't reveal if email exists + res.json({ message: 'If an account exists, a reset link has been sent.' }); + return; + } + + // In production, send email here. For now, return the token in dev mode. + const resetUrl = `https://app.squaremcp.com/reset-password?token=${token}`; + res.json({ + message: 'Password reset link generated.', + resetUrl, + token, + }); +}); + +app.post('/api/auth/reset-password', express.json(), async (req, res) => { + const { token, password } = req.body as Record; + if (!token || !password) { + res.status(400).json({ error: 'Token and password required' }); + return; + } + if (password.length < 8) { + res.status(400).json({ error: 'Password must be at least 8 characters' }); + return; + } + + const customer = await findCustomerByResetToken(token); + if (!customer) { + res.status(400).json({ error: 'Invalid or expired reset token' }); + return; + } + + const passwordHash = await hashPassword(password); + await updatePassword(customer.id, passwordHash); + await clearResetToken(customer.id); + + res.json({ message: 'Password updated successfully.' }); +}); + +// ── Admin Endpoints ───────────────────────────────────────────── + +function callTool(req: express.Request, name: string, args: Record) { + const customer = (req as express.Request & { customer?: Customer }).customer; + return handleToolCall(name, args, customer); +} + +async function requireAdmin(req: express.Request, res: express.Response, next: express.NextFunction) { + // Global API key = superadmin access + const apiKeyProvided = (req.headers['x-api-key'] as string | undefined) || (req.query.key as string | undefined); + if (apiKeyProvided === API_KEY) return next(); + + // Check JWT first + const jwtCookie = req.cookies?.session; + let customerId: string | null = null; + + if (jwtCookie) { + try { + const payload = verifyJWT(jwtCookie); + customerId = payload.sub; + } catch { + // fall through + } + } + + // Check customer API key + if (!customerId && apiKeyProvided) { + const customer = await resolveCustomerByApiKey(apiKeyProvided); + if (customer) customerId = customer.id; + } + + if (!customerId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const [rows] = await getPool().query('SELECT role FROM customers WHERE id = ?', [customerId]); + if (!rows.length || rows[0].role !== 'admin') { + res.status(403).json({ error: 'Admin access required' }); + return; + } + + next(); +} + +app.get('/api/admin/customers', requireAdmin, async (req, res) => { + const [rows] = await getPool().query( + 'SELECT id, email, plan, active, role, created_at FROM customers ORDER BY created_at DESC' + ); + res.json({ customers: rows }); +}); + +app.get('/api/admin/customers/:id/usage', requireAdmin, async (req, res) => { + const customerId = req.params.id; + const used = await getMonthlyUsage(customerId); + const breakdown = await getUsageBreakdown(customerId); + res.json({ customerId, used, breakdown }); +}); + +app.post('/api/admin/customers/:id/invoice', requireAdmin, async (req, res) => { + const customerId = req.params.id; + const invoice = await generateMonthlyInvoice(customerId); + if (!invoice) { + res.status(400).json({ error: 'No usage to invoice' }); + return; + } + res.json(invoice); +}); + +app.post('/api/admin/invoices/:number/send', requireAdmin, async (req, res) => { + await markInvoiceSent(req.params.number); + res.json({ sent: true }); +}); + +app.post('/api/admin/invoices/:number/pay', requireAdmin, async (req, res) => { + await markInvoicePaid(req.params.number); + res.json({ paid: true }); +}); + // ── LinkedIn REST endpoints ───────────────────────────────────── app.get('/api/linkedin/profile', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; try { - const result = await handleToolCall('linkedin_get_profile', { account }); + const result = await callTool(req, 'linkedin_get_profile', { account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1028,7 +1312,7 @@ app.post('/api/linkedin/post', requireAuth, async (req, res) => { const { text, visibility, account } = req.body as Record; if (!text) { res.status(400).json({ error: 'text is required' }); return; } try { - const result = await handleToolCall('linkedin_create_post', { text, visibility, account }); + const result = await callTool(req, 'linkedin_create_post', { text, visibility, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1038,7 +1322,7 @@ app.post('/api/linkedin/post', requireAuth, async (req, res) => { app.post('/api/linkedin/video', requireAuth, async (req, res) => { try { const { video_url, text, visibility, account } = req.body as Record; - const result = await handleToolCall('linkedin_upload_video', { video_url, text, visibility, account }); + const result = await callTool(req, 'linkedin_upload_video', { video_url, text, visibility, account }); res.json(result); } catch (err) { res.status(500).json({ error: String(err) }); @@ -1048,7 +1332,7 @@ app.post('/api/linkedin/video', requireAuth, async (req, res) => { app.post('/api/linkedin/search-connections', requireAuth, async (req, res) => { const { keywords, account } = req.body as Record; try { - const result = await handleToolCall('linkedin_search_connections', { keywords, account }); + const result = await callTool(req, 'linkedin_search_connections', { keywords, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1059,7 +1343,7 @@ app.post('/api/linkedin/message', requireAuth, async (req, res) => { const { recipient_id, message, account } = req.body as Record; if (!recipient_id || !message) { res.status(400).json({ error: 'recipient_id and message are required' }); return; } try { - const result = await handleToolCall('linkedin_send_message', { recipient_id, message, account }); + const result = await callTool(req, 'linkedin_send_message', { recipient_id, message, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1070,7 +1354,7 @@ app.post('/api/linkedin/message', requireAuth, async (req, res) => { app.get('/api/telegram/me', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; try { - const result = await handleToolCall('telegram_get_me', { account }); + const result = await callTool(req, 'telegram_get_me', { account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1081,7 +1365,7 @@ app.post('/api/telegram/message', requireAuth, async (req, res) => { const { chat_id, text, parse_mode, account } = req.body as Record; if (!chat_id || !text) { res.status(400).json({ error: 'chat_id and text are required' }); return; } try { - const result = await handleToolCall('telegram_send_message', { chat_id, text, parse_mode, account }); + const result = await callTool(req, 'telegram_send_message', { chat_id, text, parse_mode, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1092,7 +1376,7 @@ app.post('/api/telegram/photo', requireAuth, async (req, res) => { const { chat_id, photo, caption, account } = req.body as Record; if (!chat_id || !photo) { res.status(400).json({ error: 'chat_id and photo are required' }); return; } try { - const result = await handleToolCall('telegram_send_photo', { chat_id, photo, caption, account }); + const result = await callTool(req, 'telegram_send_photo', { chat_id, photo, caption, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1103,7 +1387,7 @@ app.get('/api/telegram/updates', requireAuth, async (req, res) => { const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined; const account = req.query.account as string | undefined; try { - const result = await handleToolCall('telegram_get_updates', { limit, account }); + const result = await callTool(req, 'telegram_get_updates', { limit, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1115,7 +1399,7 @@ app.get('/api/telegram/chat', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; if (!chat_id) { res.status(400).json({ error: 'chat_id is required' }); return; } try { - const result = await handleToolCall('telegram_get_chat', { chat_id, account }); + const result = await callTool(req, 'telegram_get_chat', { chat_id, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1126,7 +1410,7 @@ app.get('/api/telegram/chat', requireAuth, async (req, res) => { app.get('/api/discord/me', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; try { - const result = await handleToolCall('discord_get_me', { account }); + const result = await callTool(req, 'discord_get_me', { account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1136,7 +1420,7 @@ app.get('/api/discord/me', requireAuth, async (req, res) => { app.get('/api/discord/guilds', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; try { - const result = await handleToolCall('discord_get_guilds', { account }); + const result = await callTool(req, 'discord_get_guilds', { account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1148,7 +1432,7 @@ app.get('/api/discord/channels', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; if (!guild_id) { res.status(400).json({ error: 'guild_id is required' }); return; } try { - const result = await handleToolCall('discord_get_channels', { guild_id, account }); + const result = await callTool(req, 'discord_get_channels', { guild_id, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1159,7 +1443,7 @@ app.post('/api/discord/message', requireAuth, async (req, res) => { const { channel_id, content, account } = req.body as Record; if (!channel_id || !content) { res.status(400).json({ error: 'channel_id and content are required' }); return; } try { - const result = await handleToolCall('discord_send_message', { channel_id, content, account }); + const result = await callTool(req, 'discord_send_message', { channel_id, content, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1172,7 +1456,7 @@ app.get('/api/discord/messages', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; if (!channel_id) { res.status(400).json({ error: 'channel_id is required' }); return; } try { - const result = await handleToolCall('discord_get_messages', { channel_id, limit, account }); + const result = await callTool(req, 'discord_get_messages', { channel_id, limit, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1183,7 +1467,7 @@ app.get('/api/discord/messages', requireAuth, async (req, res) => { app.get('/api/instagram/profile', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; try { - const result = await handleToolCall('instagram_get_profile', { account }); + const result = await callTool(req, 'instagram_get_profile', { account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1194,7 +1478,7 @@ app.get('/api/instagram/media', requireAuth, async (req, res) => { const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined; const account = req.query.account as string | undefined; try { - const result = await handleToolCall('instagram_get_media', { limit, account }); + const result = await callTool(req, 'instagram_get_media', { limit, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1205,7 +1489,7 @@ app.post('/api/instagram/post', requireAuth, async (req, res) => { const { image_url, caption, account } = req.body as Record; if (!image_url) { res.status(400).json({ error: 'image_url is required' }); return; } try { - const result = await handleToolCall('instagram_create_post', { image_url, caption, account }); + const result = await callTool(req, 'instagram_create_post', { image_url, caption, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1216,7 +1500,7 @@ app.post('/api/instagram/reel', requireAuth, async (req, res) => { const { video_url, caption, account } = req.body as Record; if (!video_url) { res.status(400).json({ error: 'video_url is required' }); return; } try { - const result = await handleToolCall('instagram_create_reel', { video_url, caption, account }); + const result = await callTool(req, 'instagram_create_reel', { video_url, caption, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1230,7 +1514,7 @@ app.get('/api/twitter/search', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; if (!query) { res.status(400).json({ error: 'query is required' }); return; } try { - const result = await handleToolCall('twitter_search_tweets', { query, max_results, account }); + const result = await callTool(req, 'twitter_search_tweets', { query, max_results, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1242,7 +1526,7 @@ app.get('/api/twitter/user', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; if (!username) { res.status(400).json({ error: 'username is required' }); return; } try { - const result = await handleToolCall('twitter_get_user_profile', { username, account }); + const result = await callTool(req, 'twitter_get_user_profile', { username, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1255,7 +1539,7 @@ app.get('/api/twitter/tweets', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; if (!username) { res.status(400).json({ error: 'username is required' }); return; } try { - const result = await handleToolCall('twitter_get_user_tweets', { username, max_results, account }); + const result = await callTool(req, 'twitter_get_user_tweets', { username, max_results, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1266,7 +1550,7 @@ app.post('/api/twitter/tweet', requireAuth, async (req, res) => { const { text, account } = req.body as Record; if (!text) { res.status(400).json({ error: 'text is required' }); return; } try { - const result = await handleToolCall('twitter_create_tweet', { text, account }); + const result = await callTool(req, 'twitter_create_tweet', { text, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1277,7 +1561,7 @@ app.post('/api/twitter/video', requireAuth, async (req, res) => { const { video_url, text, account } = req.body as Record; if (!video_url || !text) { res.status(400).json({ error: 'video_url and text are required' }); return; } try { - const result = await handleToolCall('twitter_upload_video', { video_url, text, account }); + const result = await callTool(req, 'twitter_upload_video', { video_url, text, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1288,7 +1572,7 @@ app.post('/api/twitter/video', requireAuth, async (req, res) => { app.get('/api/facebook/page', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; try { - const result = await handleToolCall('facebook_get_page', { account }); + const result = await callTool(req, 'facebook_get_page', { account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1299,7 +1583,7 @@ app.get('/api/facebook/posts', requireAuth, async (req, res) => { const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined; const account = req.query.account as string | undefined; try { - const result = await handleToolCall('facebook_get_posts', { limit, account }); + const result = await callTool(req, 'facebook_get_posts', { limit, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1310,7 +1594,7 @@ app.post('/api/facebook/post', requireAuth, async (req, res) => { const { message, link, account } = req.body as Record; if (!message) { res.status(400).json({ error: 'message is required' }); return; } try { - const result = await handleToolCall('facebook_create_post', { message, link, account }); + const result = await callTool(req, 'facebook_create_post', { message, link, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1321,7 +1605,7 @@ app.post('/api/facebook/photo', requireAuth, async (req, res) => { const { image_url, caption, account } = req.body as Record; if (!image_url) { res.status(400).json({ error: 'image_url is required' }); return; } try { - const result = await handleToolCall('facebook_create_photo_post', { image_url, caption, account }); + const result = await callTool(req, 'facebook_create_photo_post', { image_url, caption, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1332,7 +1616,7 @@ app.post('/api/facebook/video', requireAuth, async (req, res) => { const { video_url, description, account } = req.body as Record; if (!video_url) { res.status(400).json({ error: 'video_url is required' }); return; } try { - const result = await handleToolCall('facebook_create_video_post', { video_url, description, account }); + const result = await callTool(req, 'facebook_create_video_post', { video_url, description, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1343,7 +1627,7 @@ app.post('/api/facebook/video', requireAuth, async (req, res) => { app.get('/api/tiktok/profile', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; try { - const result = await handleToolCall('tiktok_get_profile', { account }); + const result = await callTool(req, 'tiktok_get_profile', { account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1353,7 +1637,7 @@ app.get('/api/tiktok/profile', requireAuth, async (req, res) => { app.get('/api/tiktok/creator-info', requireAuth, async (req, res) => { const account = req.query.account as string | undefined; try { - const result = await handleToolCall('tiktok_get_creator_info', { account }); + const result = await callTool(req, 'tiktok_get_creator_info', { account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1364,7 +1648,7 @@ app.post('/api/tiktok/video', requireAuth, async (req, res) => { const { video_url, title, description, account } = req.body as Record; if (!video_url) { res.status(400).json({ error: 'video_url is required' }); return; } try { - const result = await handleToolCall('tiktok_create_video', { video_url, title, description, account }); + const result = await callTool(req, 'tiktok_create_video', { video_url, title, description, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -1375,7 +1659,7 @@ app.post('/api/tiktok/video/status', requireAuth, async (req, res) => { const { publish_id, account } = req.body as Record; if (!publish_id) { res.status(400).json({ error: 'publish_id is required' }); return; } try { - const result = await handleToolCall('tiktok_get_video_status', { publish_id, account }); + const result = await callTool(req, 'tiktok_get_video_status', { publish_id, account }); res.json(parseToolResult(result)); } catch (err) { res.status(500).json({ error: (err as Error).message }); diff --git a/src/tools.ts b/src/tools.ts index 35a0602..7fd3766 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1,5 +1,6 @@ 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 { sendEmail, createDraft } from './smtp.js'; import { searchNotes, getNote, appendToNote, updateNote, getSyncStatus } from './clients/obsidian.js'; @@ -1124,6 +1125,10 @@ 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(() => {}); + } return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; diff --git a/videos/remotion-demo/README.md b/videos/remotion-demo/README.md index 3132845..fdb8581 100644 --- a/videos/remotion-demo/README.md +++ b/videos/remotion-demo/README.md @@ -1,54 +1,72 @@ -# Remotion video +# SquareMCP Remotion Workflow -

- - - - Animated Remotion Logo - - -

+This directory is the canonical video-production workspace for SquareMCP marketing assets inside the `hermes-mcp` repo. -Welcome to your Remotion project! +## What lives here -## Commands +- `SquareMCPLinkedIn`: 90-second 1080x1080 explainer +- `SquareMCPHeroLoop`: 30-second 1920x1080 seamless site hero loop +- `SquareMCPTikTokFull`: 30-second 1080x1920 short-form vertical launch video +- `SquareMCPTikTokHook`, `SquareMCPTikTokProblem`, `SquareMCPTikTokDemo`, `SquareMCPTikTokProof`, `SquareMCPTikTokCTA`: standalone cutdowns for platform testing -**Install Dependencies** +## Install -```console -npm i +```bash +cd videos/remotion-demo +npm install ``` -**Start Preview** +## Preview in Studio -```console +```bash +cd videos/remotion-demo npm run dev ``` -**Render video** +Remotion Studio will open on `http://localhost:3000`. -```console -npx remotion render +## Render Commands + +Full LinkedIn video: + +```bash +cd videos/remotion-demo +npx remotion render src/index.ts SquareMCPLinkedIn out/squaremcp-linkedin.mp4 ``` -**Upgrade Remotion** +Full site hero loop: -```console -npx remotion upgrade +```bash +cd videos/remotion-demo +npx remotion render src/index.ts SquareMCPHeroLoop out/squaremcp-hero-loop.mp4 ``` -## Docs +Full TikTok video: -Get started with Remotion by reading the [fundamentals page](https://www.remotion.dev/docs/the-fundamentals). +```bash +cd videos/remotion-demo +npx remotion render src/index.ts SquareMCPTikTokFull out/squaremcp-tiktok-full.mp4 +``` -## Help +TikTok cutdowns: -We provide help on our [Discord server](https://discord.gg/6VzzNDwUwV). +```bash +cd videos/remotion-demo +npx remotion render src/index.ts SquareMCPTikTokHook out/squaremcp-tiktok-hook.mp4 +npx remotion render src/index.ts SquareMCPTikTokProblem out/squaremcp-tiktok-problem.mp4 +npx remotion render src/index.ts SquareMCPTikTokDemo out/squaremcp-tiktok-demo.mp4 +npx remotion render src/index.ts SquareMCPTikTokProof out/squaremcp-tiktok-proof.mp4 +npx remotion render src/index.ts SquareMCPTikTokCTA out/squaremcp-tiktok-cta.mp4 +``` -## Issues +## Project conventions -Found an issue with Remotion? [File an issue here](https://github.com/remotion-dev/remotion/issues/new). +- Keep assets brand-aligned with `src/styles.ts` +- Put reusable animations in dedicated scene files under `src/scenes/` +- Render outputs to `out/`; the repo already ignores that directory +- Do not mention Remotion in public-facing social copy -## License +## Notes -Note that for some entities a company license is needed. [Read the terms here](https://github.com/remotion-dev/remotion/blob/main/LICENSE.md). +- Logo source for video work lives in `public/squaremcp-logo.svg` +- The May 11 notes in the synced vault describe the original LinkedIn and hero-loop workflow diff --git a/videos/remotion-demo/package-lock.json b/videos/remotion-demo/package-lock.json index 7db414f..0cde814 100644 --- a/videos/remotion-demo/package-lock.json +++ b/videos/remotion-demo/package-lock.json @@ -1002,9 +1002,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "optional": true, "os": [ "linux" @@ -1017,9 +1014,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "optional": true, "os": [ "linux" @@ -1032,9 +1026,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "optional": true, "os": [ "linux" @@ -1047,9 +1038,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "optional": true, "os": [ "linux" @@ -1537,9 +1525,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1553,9 +1538,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1569,9 +1551,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1585,9 +1564,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1824,9 +1800,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1843,9 +1816,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1862,9 +1832,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1881,9 +1848,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3631,9 +3595,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3654,9 +3615,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3677,9 +3635,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3700,9 +3655,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/videos/remotion-demo/src/Root.tsx b/videos/remotion-demo/src/Root.tsx index 1cb45df..c371ed0 100644 --- a/videos/remotion-demo/src/Root.tsx +++ b/videos/remotion-demo/src/Root.tsx @@ -2,6 +2,14 @@ import "./index.css"; import { Composition } from "remotion"; import { SquareMCPLinkedIn } from "./SquareMCPLinkedIn"; import { SquareMCPHeroLoop } from "./SquareMCPHeroLoop"; +import { + SquareMCPTikTokCTA, + SquareMCPTikTokDemo, + SquareMCPTikTokFull, + SquareMCPTikTokHook, + SquareMCPTikTokProblem, + SquareMCPTikTokProof, +} from "./SquareMCPTikTok"; export const RemotionRoot = () => { return ( @@ -22,6 +30,54 @@ export const RemotionRoot = () => { width={1920} height={1080} /> + + + + + + ); }; diff --git a/videos/remotion-demo/src/SquareMCPTikTok.tsx b/videos/remotion-demo/src/SquareMCPTikTok.tsx new file mode 100644 index 0000000..867760e --- /dev/null +++ b/videos/remotion-demo/src/SquareMCPTikTok.tsx @@ -0,0 +1,81 @@ +import type { ReactNode } from "react"; +import { AbsoluteFill, Sequence, useVideoConfig } from "remotion"; +import { TikTokBackground } from "./scenes/tiktok/TikTokBackground"; +import { TikTokCTA } from "./scenes/tiktok/TikTokCTA"; +import { TikTokDemo } from "./scenes/tiktok/TikTokDemo"; +import { TikTokHook } from "./scenes/tiktok/TikTokHook"; +import { TikTokProblem } from "./scenes/tiktok/TikTokProblem"; +import { TikTokProof } from "./scenes/tiktok/TikTokProof"; + +const TikTokShell = ({ children }: { children: ReactNode }) => { + return ( + + + {children} + + ); +}; + +export const SquareMCPTikTokFull = () => { + const { fps } = useVideoConfig(); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const SquareMCPTikTokHook = () => { + return ( + + + + ); +}; + +export const SquareMCPTikTokProblem = () => { + return ( + + + + ); +}; + +export const SquareMCPTikTokDemo = () => { + return ( + + + + ); +}; + +export const SquareMCPTikTokProof = () => { + return ( + + + + ); +}; + +export const SquareMCPTikTokCTA = () => { + return ( + + + + ); +}; diff --git a/videos/remotion-demo/src/scenes/tiktok/TikTokBackground.tsx b/videos/remotion-demo/src/scenes/tiktok/TikTokBackground.tsx new file mode 100644 index 0000000..e4c4cc6 --- /dev/null +++ b/videos/remotion-demo/src/scenes/tiktok/TikTokBackground.tsx @@ -0,0 +1,26 @@ +import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion"; +import { COLORS } from "../../styles"; + +export const TikTokBackground = () => { + const frame = useCurrentFrame(); + const orbit = interpolate(frame % 180, [0, 90, 180], [0, 1, 0]); + + return ( + +
+ + ); +}; diff --git a/videos/remotion-demo/src/scenes/tiktok/TikTokCTA.tsx b/videos/remotion-demo/src/scenes/tiktok/TikTokCTA.tsx new file mode 100644 index 0000000..4405486 --- /dev/null +++ b/videos/remotion-demo/src/scenes/tiktok/TikTokCTA.tsx @@ -0,0 +1,69 @@ +import { AbsoluteFill, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion"; +import { COLORS, FONT, SPRING_CFG } from "../../styles"; + +export const TikTokCTA = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const logoIn = spring({ fps, frame, config: SPRING_CFG }); + const glow = interpolate(frame % 60, [0, 30, 60], [0.32, 0.7, 0.32]); + + return ( + +
+
+ +
+ SquareMCP +
+
+ squaremcp.com +
+
+ Early access now open +
+
+ + ); +}; diff --git a/videos/remotion-demo/src/scenes/tiktok/TikTokDemo.tsx b/videos/remotion-demo/src/scenes/tiktok/TikTokDemo.tsx new file mode 100644 index 0000000..36868d5 --- /dev/null +++ b/videos/remotion-demo/src/scenes/tiktok/TikTokDemo.tsx @@ -0,0 +1,124 @@ +import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"; +import { COLORS, FONT, SPRING_CFG } from "../../styles"; + +export const TikTokDemo = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const chatIn = spring({ fps, frame, config: SPRING_CFG }); + const jsonIn = spring({ fps, frame: Math.max(0, frame - 30), config: SPRING_CFG }); + const chipsIn = spring({ fps, frame: Math.max(0, frame - 70), config: SPRING_CFG }); + const pulse = interpolate(frame % 45, [0, 22, 45], [0.75, 1, 0.75]); + + return ( + +
+ One prompt. +
+ Real execution. +
+ +
+
Chat
+
+ Post my launch video to LinkedIn, X, and Instagram. +
+
+ +
+
+ response.json +
+ {[ + '{', + ' "status": "success",', + ' "published": ["linkedin", "x", "instagram"],', + ' "duration_seconds": 12,', + ' "urls": 3', + '}', + ].map((line) => ( +
+ {line} +
+ ))} +
+ +
+ {["linkedin.com/post/7459...", "x.com/squaremcp/status/...", "instagram.com/reel/C..."].map((url) => ( +
+
+
{url}
+
+ ))} +
+ + ); +}; diff --git a/videos/remotion-demo/src/scenes/tiktok/TikTokHook.tsx b/videos/remotion-demo/src/scenes/tiktok/TikTokHook.tsx new file mode 100644 index 0000000..c1766ad --- /dev/null +++ b/videos/remotion-demo/src/scenes/tiktok/TikTokHook.tsx @@ -0,0 +1,113 @@ +import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"; +import { COLORS, FONT, SPRING_CFG } from "../../styles"; + +const TOOL_LABELS = [ + "Email", + "Notes", + "CRM", + "LinkedIn", + "X", + "WhatsApp", + "Docs", + "Sheets", + "Tickets", + "Calendar", + "DB", + "API", +]; + +export const TikTokHook = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const terminalIn = spring({ fps, frame, config: SPRING_CFG }); + const gridIn = spring({ fps, frame: Math.max(0, frame - 18), config: SPRING_CFG }); + const captionIn = interpolate(frame, [20, 34], [0, 1], { extrapolateRight: "clamp" }); + + return ( + +
+
+ {["#ff5f57", "#febc2e", "#28c840"].map((dot) => ( +
+ ))} +
+
+ {"> connect squaremcp"} + | +
+
+ +
+ {TOOL_LABELS.map((label, index) => ( +
+ {label} +
+ ))} +
+ +
+ One API key. +
+ One workflow layer. +
+ + ); +}; diff --git a/videos/remotion-demo/src/scenes/tiktok/TikTokProblem.tsx b/videos/remotion-demo/src/scenes/tiktok/TikTokProblem.tsx new file mode 100644 index 0000000..51a73ba --- /dev/null +++ b/videos/remotion-demo/src/scenes/tiktok/TikTokProblem.tsx @@ -0,0 +1,131 @@ +import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"; +import { COLORS, FONT, SPRING_CFG } from "../../styles"; + +const LEFT_APPS = ["Email", "LinkedIn", "Slack", "Notes", "Sheets", "Docs"]; + +export const TikTokProblem = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const leftIn = spring({ fps, frame, config: SPRING_CFG }); + const rightIn = spring({ fps, frame: Math.max(0, frame - 12), config: SPRING_CFG }); + const divider = interpolate(frame, [4, 24], [0.25, 1], { extrapolateRight: "clamp" }); + + return ( + +
+ 8 tabs vs +
+ 1 gateway +
+
+
+
+
+ Before +
+
+ {LEFT_APPS.map((app, index) => ( +
+ {app} +
+ ))} +
+
+
+ +
+ +
+
+
+ After +
+ {["AI agent", "SquareMCP", "Connected tools"].map((item, index) => ( +
+ {item} +
+ ))} +
+ One entry point for auth, permissions, posting, notes, and internal systems. +
+
+
+
+ + ); +}; diff --git a/videos/remotion-demo/src/scenes/tiktok/TikTokProof.tsx b/videos/remotion-demo/src/scenes/tiktok/TikTokProof.tsx new file mode 100644 index 0000000..c6f3913 --- /dev/null +++ b/videos/remotion-demo/src/scenes/tiktok/TikTokProof.tsx @@ -0,0 +1,58 @@ +import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "remotion"; +import { COLORS, FONT, SPRING_CFG } from "../../styles"; + +const CHANNELS = [ + { name: "LinkedIn", time: "12:04:03", tint: "rgba(10, 102, 194, 0.28)" }, + { name: "X", time: "12:04:05", tint: "rgba(255, 255, 255, 0.08)" }, + { name: "Instagram", time: "12:04:07", tint: "rgba(225, 48, 108, 0.24)" }, + { name: "TikTok", time: "12:04:09", tint: "rgba(37, 244, 238, 0.18)" }, +]; + +export const TikTokProof = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const inView = spring({ fps, frame, config: SPRING_CFG }); + + return ( + +
+ Publish once. +
+ Watch it land. +
+
+ {CHANNELS.map((channel, index) => ( +
+
{channel.name}
+
LIVE
+
+ {channel.time} +
+
+ ))} +
+
+ ); +};