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 = `
+
+ | Email | Plan | Role | Active | Joined | Actions |
+
+ ${customers.map(c => `
+
+ | ${c.email} |
+ ${c.plan} |
+ ${c.role} |
+ ${c.active ? 'Yes' : 'No'} |
+ ${new Date(c.created_at).toLocaleDateString()} |
+ |
+
+ `).join('')}
+
+
+ `;
+ } 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 `
+
+ `;
+ }
+
+ const fieldsHtml = cfg.fields.map(f => `
+
+
+ `).join('');
+
+ return `
+
+ `;
+}
+
+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
+
+
+
+
+
+
+
+
+
S
+
Reset Password
+
Enter your email to receive a reset link
+
+
+
+
+
+
+
+
+
+
S
+
New Password
+
Enter your new password below
+
+
+
+
+
+
+
+
+
+
S
+
SquareMCP
+
AI Social Media Gateway
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Connect your platforms
+ Link your social accounts to publish, analyze, and manage content from one place.
+
+
+
+
+ Free
+ 0 / 100 calls this month
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
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
-
-
-
-
-
-
-
-
+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) => (
+
+ ))}
+
+
+ );
+};
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}
+
+
+ ))}
+
+
+ );
+};