feat(saas): full SquareMCP SaaS platform v1

- JWT auth with bcrypt password hashing, cookie sessions, forgot/reset password
- Per-user encrypted credential storage (Redis + AES-256-GCM) for all 9 platforms
- Usage tracking with monthly limits per plan (free/starter/growth/enterprise)
- Invoice generation and retrieval (admin + user views)
- Admin panel with customer listing (role-based access)
- Web app UI at app.squaremcp.com — login, dashboard, connections, usage, invoices
- Unified auth middleware: API key, OAuth Bearer, and JWT cookie support
- Facebook Graph API fixes: published_posts endpoint, photo/video post support
- TikTok sandbox compliance: SELF_ONLY privacy for unaudited apps
- URL verification files for TikTok app review
This commit is contained in:
Garfield
2026-05-13 08:42:33 -04:00
parent 7796de12bf
commit a5e4c55885
46 changed files with 4054 additions and 171 deletions

View File

@@ -81,3 +81,20 @@ INSTAGRAM_DEFAULT_BUSINESS_ACCOUNT_ID=your-instagram-business-account-id
# For default account: # For default account:
TWITTER_DEFAULT_BEARER_TOKEN=your-twitter-bearer-token TWITTER_DEFAULT_BEARER_TOKEN=your-twitter-bearer-token
# For additional accounts, duplicate with TWITTER_{ACCOUNT}_* # 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}_*

1
.gitignore vendored
View File

@@ -17,4 +17,5 @@ opencode.json
# Remotion demo project # Remotion demo project
videos/remotion-demo/node_modules videos/remotion-demo/node_modules
videos/remotion-demo/build
videos/remotion-demo/out videos/remotion-demo/out

View File

@@ -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) 2. [CLI agent setup (Claude Code, generic MCP CLIs, Claude Desktop)](./AGENTS_CLI_SETUP.md)
3. [opencode setup](./OPENCODE.md) 3. [opencode setup](./OPENCODE.md)
4. [ChatGPT Custom GPT setup](./CHATGPT_SETUP.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) 1. [`product/site`](./product/site)
2. [`product/README.md`](./product/README.md) 2. [`product/README.md`](./product/README.md)
3. [`videos/remotion-demo`](./videos/remotion-demo/README.md) for SquareMCP video production assets and render workflows
--- ---

321
SETUP_GUIDE.md Normal file
View File

@@ -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=
```

118
SOCIAL_PUBLISHING_SETUP.md Normal file
View File

@@ -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

187
package-lock.json generated
View File

@@ -10,16 +10,22 @@
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0", "@modelcontextprotocol/sdk": "^1.0.0",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"express": "^4.18.0", "express": "^4.18.0",
"imapflow": "^1.0.0", "imapflow": "^1.0.0",
"jsonwebtoken": "^9.0.3",
"mysql2": "^3.14.0", "mysql2": "^3.14.0",
"nodemailer": "^6.9.0", "nodemailer": "^6.9.0",
"redis": "^5.12.1" "redis": "^5.12.1"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^4.17.0", "@types/express": "^4.17.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@types/nodemailer": "^6.4.0", "@types/nodemailer": "^6.4.0",
"pixelmatch": "^7.1.0", "pixelmatch": "^7.1.0",
@@ -886,6 +892,13 @@
"@redis/client": "^5.12.1" "@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": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -907,6 +920,16 @@
"@types/node": "*" "@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": { "node_modules/@types/cors": {
"version": "2.8.19", "version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@@ -949,6 +972,17 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/mime": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -956,6 +990,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "20.19.37", "version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
@@ -1103,6 +1144,15 @@
"node": ">= 6.0.0" "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": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -1142,6 +1192,12 @@
"node": ">= 0.8" "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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1219,6 +1275,25 @@
"node": ">= 0.6" "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": { "node_modules/cookie-signature": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@@ -1319,6 +1394,15 @@
"node": ">= 0.4" "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": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -1838,6 +1922,55 @@
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause" "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": { "node_modules/libbase64": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
@@ -1874,6 +2007,48 @@
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT" "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": { "node_modules/long": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -2447,6 +2622,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "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": { "node_modules/send": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",

View File

@@ -18,16 +18,22 @@
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0", "@modelcontextprotocol/sdk": "^1.0.0",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"express": "^4.18.0", "express": "^4.18.0",
"imapflow": "^1.0.0", "imapflow": "^1.0.0",
"jsonwebtoken": "^9.0.3",
"mysql2": "^3.14.0", "mysql2": "^3.14.0",
"nodemailer": "^6.9.0", "nodemailer": "^6.9.0",
"redis": "^5.12.1" "redis": "^5.12.1"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^4.17.0", "@types/express": "^4.17.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@types/nodemailer": "^6.4.0", "@types/nodemailer": "^6.4.0",
"pixelmatch": "^7.1.0", "pixelmatch": "^7.1.0",

6
product/app/Dockerfile Normal file
View File

@@ -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

65
product/app/app-k8s.yaml Normal file
View File

@@ -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

532
product/app/app.js Normal file
View File

@@ -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 = '<p style="color:#888;font-size:14px;">No invoices yet.</p>';
return;
}
list.innerHTML = invoices.map(inv => `
<div class="invoice-item">
<div>
<div class="inv-num">${inv.invoice_number}</div>
<div class="inv-period">${inv.period_start}${inv.period_end}</div>
</div>
<div style="text-align:right;">
<div class="inv-amount">$${inv.amount}</div>
<span class="inv-status ${inv.status}">${inv.status}</span>
</div>
</div>
`).join('');
} catch {
document.getElementById('invoices-list').innerHTML = '<p style="color:#888;font-size:14px;">Failed to load invoices.</p>';
}
}
// 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 = `
<table class="admin-table">
<thead><tr><th>Email</th><th>Plan</th><th>Role</th><th>Active</th><th>Joined</th><th>Actions</th></tr></thead>
<tbody>
${customers.map(c => `
<tr>
<td>${c.email}</td>
<td>${c.plan}</td>
<td>${c.role}</td>
<td>${c.active ? 'Yes' : 'No'}</td>
<td>${new Date(c.created_at).toLocaleDateString()}</td>
<td><button class="btn btn-small btn-connect" data-cid="${c.id}" onclick="generateInvoice('${c.id}')">Invoice</button></td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch {
document.getElementById('admin-customers').innerHTML = '<p style="color:#888;font-size:14px;padding:20px;">Failed to load admin data.</p>';
}
}
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 `
<div class="connect-form">
<h3>Connect ${cfg.name}</h3>
<p>You'll be redirected to ${cfg.name} to authorize SquareMCP.</p>
<div class="instructions">${cfg.help || ''}</div>
<a href="${url}" target="_blank" class="btn btn-primary" style="text-align:center;text-decoration:none;display:inline-block;">Connect with ${cfg.name}</a>
</div>
`;
}
const fieldsHtml = cfg.fields.map(f => `
<label>${f.label}</label>
<input type="${f.type}" name="${f.key}" placeholder="${f.label}" required>
`).join('');
return `
<form class="connect-form" data-platform="${platform}">
<h3>Connect ${cfg.name}</h3>
<p>Paste your credentials below. They are encrypted and stored securely.</p>
${fieldsHtml}
<div class="instructions">${cfg.help}</div>
<button type="submit" class="btn btn-primary">Save Connection</button>
<p class="error-msg" id="connect-error"></p>
</form>
`;
}
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();

230
product/app/index.html Normal file
View File

@@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SquareMCP — AI Social Gateway</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="app">
<!-- Password Reset Request View -->
<div id="reset-request-view" class="view hidden">
<div class="auth-card">
<div class="logo">
<div class="logo-mark">S</div>
<h1>Reset Password</h1>
<p>Enter your email to receive a reset link</p>
</div>
<form id="reset-request-form" class="auth-form">
<input type="email" name="email" placeholder="Email" required>
<button type="submit" class="btn btn-primary">Send Reset Link</button>
<p class="error-msg" id="reset-request-error"></p>
<p class="success-msg" id="reset-request-success"></p>
<a href="#" id="back-to-login" style="color:#888;font-size:13px;text-align:center;display:block;margin-top:12px;">Back to login</a>
</form>
</div>
</div>
<!-- Password Reset Confirm View -->
<div id="reset-confirm-view" class="view hidden">
<div class="auth-card">
<div class="logo">
<div class="logo-mark">S</div>
<h1>New Password</h1>
<p>Enter your new password below</p>
</div>
<form id="reset-confirm-form" class="auth-form">
<input type="password" name="password" placeholder="New password (min 8 chars)" required minlength="8">
<button type="submit" class="btn btn-primary">Update Password</button>
<p class="error-msg" id="reset-confirm-error"></p>
<p class="success-msg" id="reset-confirm-success"></p>
</form>
</div>
</div>
<!-- Login View -->
<div id="login-view" class="view">
<div class="auth-card">
<div class="logo">
<div class="logo-mark">S</div>
<h1>SquareMCP</h1>
<p>AI Social Media Gateway</p>
</div>
<div class="tabs">
<button class="tab-btn active" data-tab="login">Sign In</button>
<button class="tab-btn" data-tab="signup">Create Account</button>
</div>
<form id="login-form" class="auth-form">
<input type="email" name="email" placeholder="Email" required>
<input type="password" name="password" placeholder="Password" required minlength="8">
<button type="submit" class="btn btn-primary">Sign In</button>
<p class="error-msg" id="login-error"></p>
</form>
<form id="signup-form" class="auth-form hidden">
<input type="email" name="email" placeholder="Email" required>
<input type="password" name="password" placeholder="Password (min 8 chars)" required minlength="8">
<button type="submit" class="btn btn-primary">Create Account</button>
<p class="error-msg" id="signup-error"></p>
</form>
</div>
</div>
<!-- Dashboard View -->
<div id="dashboard-view" class="view hidden">
<header class="app-header">
<div class="header-left">
<div class="logo-mark small">S</div>
<span class="app-title">SquareMCP</span>
</div>
<div class="header-right">
<nav class="header-nav">
<button class="nav-link active" data-view="platforms">Platforms</button>
<button class="nav-link" data-view="invoices">Invoices</button>
<button class="nav-link hidden" data-view="admin" id="admin-nav">Admin</button>
</nav>
<span id="user-email" class="user-email"></span>
<button id="logout-btn" class="btn btn-ghost">Logout</button>
</div>
</header>
<main class="dashboard">
<section class="welcome">
<h2>Connect your platforms</h2>
<p>Link your social accounts to publish, analyze, and manage content from one place.</p>
</section>
<section class="usage-bar" id="usage-bar">
<div class="usage-info">
<span class="plan-badge" id="plan-badge">Free</span>
<span class="usage-text" id="usage-text">0 / 100 calls this month</span>
</div>
<div class="usage-bar-track"><div class="usage-bar-fill" id="usage-bar-fill" style="width:0%"></div></div>
</section>
<section class="platform-grid">
<!-- TikTok -->
<div class="platform-card" data-platform="tiktok">
<div class="platform-icon" style="background:#000;">🎵</div>
<div class="platform-info">
<h3>TikTok</h3>
<p class="platform-desc">Publish videos and view analytics</p>
<span class="status-badge disconnected">Not connected</span>
</div>
<button class="btn btn-connect" data-platform="tiktok">Connect</button>
</div>
<!-- Facebook -->
<div class="platform-card" data-platform="facebook">
<div class="platform-icon" style="background:#1877f2;">f</div>
<div class="platform-info">
<h3>Facebook</h3>
<p class="platform-desc">Post to pages and manage content</p>
<span class="status-badge disconnected">Not connected</span>
</div>
<button class="btn btn-connect" data-platform="facebook">Connect</button>
</div>
<!-- Instagram -->
<div class="platform-card" data-platform="instagram">
<div class="platform-icon" style="background:linear-gradient(45deg,#f09433,#e6683c,#dc2743,#cc2366,#bc1888);">📷</div>
<div class="platform-info">
<h3>Instagram</h3>
<p class="platform-desc">Publish reels and images</p>
<span class="status-badge disconnected">Not connected</span>
</div>
<button class="btn btn-connect" data-platform="instagram">Connect</button>
</div>
<!-- LinkedIn -->
<div class="platform-card" data-platform="linkedin">
<div class="platform-icon" style="background:#0a66c2;">in</div>
<div class="platform-info">
<h3>LinkedIn</h3>
<p class="platform-desc">Share posts, images, and videos</p>
<span class="status-badge disconnected">Not connected</span>
</div>
<button class="btn btn-connect" data-platform="linkedin">Connect</button>
</div>
<!-- Twitter/X -->
<div class="platform-card" data-platform="twitter">
<div class="platform-icon" style="background:#000;">𝕏</div>
<div class="platform-info">
<h3>Twitter / X</h3>
<p class="platform-desc">Tweet with media support</p>
<span class="status-badge disconnected">Not connected</span>
</div>
<button class="btn btn-connect" data-platform="twitter">Connect</button>
</div>
<!-- Telegram -->
<div class="platform-card" data-platform="telegram">
<div class="platform-icon" style="background:#0088cc;">✈️</div>
<div class="platform-info">
<h3>Telegram</h3>
<p class="platform-desc">Send messages via bot</p>
<span class="status-badge disconnected">Not connected</span>
</div>
<button class="btn btn-connect" data-platform="telegram">Connect</button>
</div>
<!-- Discord -->
<div class="platform-card" data-platform="discord">
<div class="platform-icon" style="background:#5865f2;">🎮</div>
<div class="platform-info">
<h3>Discord</h3>
<p class="platform-desc">Send messages to channels</p>
<span class="status-badge disconnected">Not connected</span>
</div>
<button class="btn btn-connect" data-platform="discord">Connect</button>
</div>
<!-- WhatsApp -->
<div class="platform-card" data-platform="whatsapp">
<div class="platform-icon" style="background:#25d366;">💬</div>
<div class="platform-info">
<h3>WhatsApp</h3>
<p class="platform-desc">Business messaging</p>
<span class="status-badge disconnected">Not connected</span>
</div>
<button class="btn btn-connect" data-platform="whatsapp">Connect</button>
</div>
<!-- Email -->
<div class="platform-card" data-platform="email">
<div class="platform-icon" style="background:#ea4335;">✉️</div>
<div class="platform-info">
<h3>Email</h3>
<p class="platform-desc">IMAP/SMTP accounts</p>
<span class="status-badge disconnected">Not connected</span>
</div>
<button class="btn btn-connect" data-platform="email">Connect</button>
</div>
</section>
<section class="invoices-section hidden" id="invoices-section">
<h3>Invoices</h3>
<div id="invoices-list" class="invoices-list"></div>
</section>
<section class="admin-section hidden" id="admin-section">
<h3>Admin Panel</h3>
<div id="admin-customers" class="admin-customers"></div>
</section>
</main>
</div>
<!-- Connection Modal -->
<div id="connect-modal" class="modal hidden">
<div class="modal-backdrop"></div>
<div class="modal-content">
<button class="modal-close">&times;</button>
<div id="modal-body"></div>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

View File

@@ -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";
}
}

610
product/app/styles.css Normal file
View File

@@ -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;
}

View File

@@ -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/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-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-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.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.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

View File

@@ -1,6 +1,6 @@
server { server {
listen 8080; listen 8080;
server_name squaremcp.com www.squaremcp.com; server_name squaremcp.com www.squaremcp.com tiktok.squaremcp.com;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;

View File

@@ -0,0 +1 @@
tiktok-developers-site-verification=XbaTJRvDkwUNzhEXou9SsogGyiDkQshF

View File

@@ -11,3 +11,4 @@ spec:
dnsNames: dnsNames:
- squaremcp.com - squaremcp.com
- www.squaremcp.com - www.squaremcp.com
- tiktok.squaremcp.com

View File

@@ -15,7 +15,7 @@ spec:
spec: spec:
containers: containers:
- name: squaremcp-site - name: squaremcp-site
image: localhost:32000/squaremcp-site:latest image: localhost:32000/squaremcp-site@sha256:395e736f1899ce0f2402e34caa95359e2eb54b5424318cf8139982e66b35a974
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 8080 - containerPort: 8080
@@ -90,8 +90,33 @@ spec:
name: squaremcp-site name: squaremcp-site
port: port:
number: 80 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: tls:
- hosts: - hosts:
- squaremcp.com - squaremcp.com
- www.squaremcp.com - www.squaremcp.com
- tiktok.squaremcp.com
secretName: squaremcp-tls secretName: squaremcp-tls

Binary file not shown.

View File

@@ -0,0 +1 @@
tiktok-developers-site-verification=JLZJ5wt0TEQdq2RTHGAZtY9ofrtPFBeM

Binary file not shown.

View File

@@ -0,0 +1,414 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SquareMCP - TikTok Integration Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #f7f7f8;
color: #1a1a1a;
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: #fff;
border-bottom: 1px solid #e5e5e5;
padding: 14px 24px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.header img { width: 32px; height: 32px; border-radius: 6px; }
.header h1 { font-size: 16px; font-weight: 600; color: #1a1a1a; }
.header .badge {
margin-left: auto;
background: #e3f2fd;
color: #1976d2;
font-size: 11px;
font-weight: 600;
padding: 4px 10px;
border-radius: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.chat {
flex: 1;
overflow-y: auto;
padding: 24px;
max-width: 800px;
width: 100%;
margin: 0 auto;
}
.message {
display: flex;
gap: 16px;
margin-bottom: 24px;
opacity: 0;
transform: translateY(12px);
transition: all 0.5s ease;
}
.message.visible { opacity: 1; transform: translateY(0); }
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
color: #fff;
}
.user .avatar { background: #10a37f; }
.assistant .avatar { background: #1a1a1a; }
.bubble {
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 12px;
padding: 16px 20px;
max-width: 640px;
line-height: 1.6;
font-size: 15px;
}
.user .bubble { background: #f0f9f6; border-color: #c8e6d5; }
.bubble strong { color: #1a1a1a; }
.bubble code {
background: #f4f4f5;
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 13px;
}
.bubble pre {
background: #1a1a1a;
color: #e5e5e5;
padding: 14px;
border-radius: 8px;
overflow-x: auto;
font-size: 13px;
margin-top: 10px;
}
.bubble .btn {
display: inline-block;
background: #1a1a1a;
color: #fff;
padding: 10px 20px;
border-radius: 8px;
text-decoration: none;
font-size: 14px;
font-weight: 500;
margin-top: 10px;
cursor: pointer;
border: none;
}
.bubble .btn:hover { background: #333; }
.bubble .btn.tiktok {
background: #000;
background-image: linear-gradient(135deg, #25f4ee, #fe2c55);
color: #fff;
}
.bubble .success { color: #059669; font-weight: 600; }
.bubble .error { color: #dc2626; font-weight: 600; }
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #e5e5e5;
border-top-color: #1a1a1a;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
.typing {
display: flex;
gap: 4px;
padding: 8px 0;
}
.typing span {
width: 8px;
height: 8px;
background: #999;
border-radius: 50%;
animation: bounce 1.2s infinite;
}
.typing span:nth-child(2) { animation-delay: 0.2s; }
.typing span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 80%, 100% { transform: translateY(0); }
40% { transform: translateY(-6px); }
}
.profile-card {
display: flex;
align-items: center;
gap: 16px;
margin-top: 12px;
padding: 14px;
background: #fafafa;
border-radius: 10px;
}
.profile-card img {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
}
.profile-card .info h3 { font-size: 16px; margin-bottom: 4px; }
.profile-card .info p { font-size: 13px; color: #666; }
.stats {
display: flex;
gap: 24px;
margin-top: 12px;
}
.stat { text-align: center; }
.stat .num { font-size: 20px; font-weight: 700; color: #1a1a1a; }
.stat .lbl { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 0.5px; }
.publish-form {
margin-top: 12px;
}
.publish-form input {
width: 100%;
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
margin-bottom: 10px;
}
.video-preview {
margin-top: 12px;
border-radius: 10px;
overflow: hidden;
max-width: 320px;
}
.video-preview video { width: 100%; display: block; }
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: #ecfdf5;
color: #059669;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
margin-top: 10px;
}
.status-badge::before {
content: '';
width: 8px;
height: 8px;
background: #059669;
border-radius: 50%;
}
</style>
</head>
<body>
<div class="header">
<div style="width:32px;height:32px;background:linear-gradient(135deg,#25f4ee,#fe2c55);border-radius:8px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:18px;">S</div>
<h1>SquareMCP</h1>
<span class="badge">TikTok Integration Demo</span>
</div>
<div class="chat" id="chat"></div>
<script>
const API_KEY = '114521f3f9e6858480d6269446a446ef';
const API_BASE = 'https://hermes.squaremcp.com';
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function addMessage(role, html) {
const chat = document.getElementById('chat');
const div = document.createElement('div');
div.className = `message ${role}`;
const avatar = role === 'user' ? 'U' : 'S';
const bg = role === 'user' ? '#10a37f' : '#1a1a1a';
div.innerHTML = `
<div class="avatar" style="background:${bg}">${avatar}</div>
<div class="bubble">${html}</div>
`;
chat.appendChild(div);
chat.scrollTop = chat.scrollHeight;
requestAnimationFrame(() => div.classList.add('visible'));
}
function addTyping() {
const chat = document.getElementById('chat');
const div = document.createElement('div');
div.className = 'message assistant';
div.id = 'typing-indicator';
div.innerHTML = `
<div class="avatar" style="background:#1a1a1a">S</div>
<div class="bubble">
<div class="typing"><span></span><span></span><span></span></div>
</div>
`;
chat.appendChild(div);
chat.scrollTop = chat.scrollHeight;
requestAnimationFrame(() => div.classList.add('visible'));
}
function removeTyping() {
const t = document.getElementById('typing-indicator');
if (t) t.remove();
}
async function apiGet(path) {
const res = await fetch(`${API_BASE}${path}`, { headers: { 'x-api-key': API_KEY } });
return res.json();
}
async function apiPost(path, body) {
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
return res.json();
}
async function runDemo() {
await sleep(1500);
// Step 1: Connect
addMessage('user', 'Connect my TikTok account to SquareMCP');
await sleep(800);
addTyping();
await sleep(1500);
removeTyping();
addMessage('assistant', `
I'll help you connect your TikTok account. Click the button below to authorize SquareMCP.
<br><br>
<button class="btn tiktok" onclick="this.textContent='Connecting...'">🎵 Connect with TikTok</button>
`);
await sleep(2500);
addTyping();
await sleep(2000);
removeTyping();
addMessage('assistant', `
<span class="success">✅ Account connected successfully!</span><br>
Welcome, <strong>Garfield Heron</strong>. Your TikTok account is now linked to SquareMCP.
`);
await sleep(2000);
// Step 2: Profile
addMessage('user', 'Show my TikTok profile and stats');
await sleep(800);
addTyping();
await sleep(1500);
removeTyping();
const profile = await apiGet('/api/tiktok/profile?account=tiktok');
addMessage('assistant', `
Here's your TikTok profile:<br>
<div class="profile-card">
<img src="${profile.avatar_url || ''}" onerror="this.style.display='none'" alt="avatar">
<div class="info">
<h3>${profile.display_name || 'Garfield Heron'}</h3>
<p>@${profile.username || 'garfieldheron'} ${profile.is_verified ? '✓' : ''}</p>
</div>
</div>
<div class="stats">
<div class="stat"><div class="num">${profile.follower_count ?? 0}</div><div class="lbl">Followers</div></div>
<div class="stat"><div class="num">${profile.following_count ?? 0}</div><div class="lbl">Following</div></div>
<div class="stat"><div class="num">${profile.likes_count ?? 0}</div><div class="lbl">Likes</div></div>
<div class="stat"><div class="num">${profile.video_count ?? 0}</div><div class="lbl">Videos</div></div>
</div>
`);
await sleep(2500);
// Step 3: Creator Info
addMessage('user', 'What are my publishing options?');
await sleep(800);
addTyping();
await sleep(1500);
removeTyping();
const creator = await apiGet('/api/tiktok/creator-info?account=tiktok');
addMessage('assistant', `
Here are your creator publishing settings:<br><br>
<strong>Creator:</strong> ${creator.creator_nickname || 'Garfield Heron'} (@${creator.creator_username || 'garfieldheron'})<br>
<strong>Max video duration:</strong> ${creator.max_video_post_duration_sec || 0} seconds<br>
<strong>Privacy options:</strong> ${(creator.privacy_level_options || []).join(', ')}<br>
<strong>Duet:</strong> ${creator.duet_disabled ? 'Disabled' : 'Enabled'}<br>
<strong>Stitch:</strong> ${creator.stitch_disabled ? 'Disabled' : 'Enabled'}<br>
<strong>Comments:</strong> ${creator.comment_disabled ? 'Disabled' : 'Enabled'}
`);
await sleep(2500);
// Step 4: Publish
addMessage('user', 'Publish this video to my TikTok: https://squaremcp.com/squaremcp-tiktok-launch.mp4');
await sleep(800);
addTyping();
await sleep(1500);
removeTyping();
addMessage('assistant', `
I'll publish that video for you.<br><br>
<div class="video-preview">
<video src="https://squaremcp.com/squaremcp-tiktok-launch.mp4" muted loop autoplay playsinline></video>
</div>
<br>
<em>Publishing with privacy: ${creator.privacy_level_options?.[0] || 'SELF_ONLY'} (sandbox restriction)</em>
`);
await sleep(2500);
addTyping();
await sleep(2000);
removeTyping();
const publish = await apiPost('/api/tiktok/video', {
video_url: 'https://squaremcp.com/squaremcp-tiktok-launch.mp4',
title: 'SquareMCP TikTok Launch 🚀',
account: 'tiktok'
});
addMessage('assistant', `
<span class="success">✅ Video published!</span><br>
<strong>Publish ID:</strong> <code>${publish.publish_id}</code><br>
<strong>Status:</strong> ${publish.status}
`);
await sleep(2000);
// Step 5: Status check
addMessage('user', 'Has the video finished processing?');
await sleep(800);
addTyping();
await sleep(2000);
removeTyping();
const status = await apiPost('/api/tiktok/video/status', {
publish_id: publish.publish_id,
account: 'tiktok'
});
addMessage('assistant', `
<div class="status-badge">${status.status}</div><br><br>
Your video has been successfully published to TikTok! You can view it in your TikTok app under your profile.
`);
await sleep(2000);
// End
addMessage('user', 'Thanks!');
await sleep(600);
addTyping();
await sleep(1000);
removeTyping();
addMessage('assistant', `
You're welcome! 🎉<br><br>
With SquareMCP, you can manage all your social media — TikTok, LinkedIn, Instagram, Facebook, Twitter/X, and more — directly from chat.
`);
}
runDemo();
</script>
</body>
</html>

View File

@@ -0,0 +1 @@
tiktok-developers-site-verification=IIxGZsCAoYNJVObSA8cYj8FyWrAu1nIW

View File

@@ -0,0 +1 @@
tiktok-developers-site-verification=ebJaRSVgFOD0hQE1YXb5XhZ3IvKw4LVn

View File

@@ -0,0 +1 @@
tiktok-developers-site-verification=wJMUoUxgRBFO9T8p08qlMgtsez0Ktr9y

View File

@@ -0,0 +1 @@
tiktok-developers-site-verification=kFNJHjzDuzvGIlXnK4MaGw3MSluybOih

View File

@@ -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);
});

98
src/auth.ts Normal file
View File

@@ -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<string> {
return hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
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<CustomerRow | null> {
const [rows] = await getPool().query<CustomerRow[]>(
'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<CustomerRow | null> {
const [rows] = await getPool().query<CustomerRow[]>(
'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<void> {
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<boolean> {
const [result] = await getPool().query<any>(
'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<CustomerRow[]>(
'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<void> {
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<void> {
await getPool().query(
'UPDATE customers SET password_hash = ? WHERE id = ?',
[passwordHash, customerId]
);
}

122
src/billing/invoices.ts Normal file
View File

@@ -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<Invoice> {
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<Invoice[]>(
'SELECT * FROM invoices WHERE invoice_number = ?',
[invoiceNumber]
);
return rows[0];
}
export async function getCustomerInvoices(customerId: string): Promise<Invoice[]> {
const [rows] = await getPool().query<Invoice[]>(
'SELECT * FROM invoices WHERE customer_id = ? ORDER BY created_at DESC',
[customerId]
);
return rows;
}
export async function getInvoiceByNumber(invoiceNumber: string): Promise<Invoice | null> {
const [rows] = await getPool().query<Invoice[]>(
'SELECT * FROM invoices WHERE invoice_number = ?',
[invoiceNumber]
);
return rows[0] ?? null;
}
export async function markInvoiceSent(invoiceNumber: string): Promise<void> {
await getPool().query(
"UPDATE invoices SET status = 'sent', sent_at = NOW() WHERE invoice_number = ?",
[invoiceNumber]
);
}
export async function markInvoicePaid(invoiceNumber: string): Promise<void> {
await getPool().query(
"UPDATE invoices SET status = 'paid', paid_at = NOW() WHERE invoice_number = ?",
[invoiceNumber]
);
}
export async function generateMonthlyInvoice(customerId: string): Promise<Invoice | null> {
const [usageRows] = await getPool().query<any[]>(
`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)
);
}

View File

@@ -4,6 +4,7 @@ import { getPool } from '../db.js';
import { getCredential, Platform, PlatformCredentials } from '../multitenancy/credential-store.js'; import { getCredential, Platform, PlatformCredentials } from '../multitenancy/credential-store.js';
import type { PlanKey } from './plans.js'; import type { PlanKey } from './plans.js';
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
import { verifyJWT } from '../auth.js';
const redis = createClient({ url: process.env.REDIS_URL }); const redis = createClient({ url: process.env.REDIS_URL });
redis.connect().catch((err) => console.error('[billing] Redis connect error:', err)); redis.connect().catch((err) => console.error('[billing] Redis connect error:', err));
@@ -24,16 +25,22 @@ interface CustomerRow extends RowDataPacket {
email: string; email: string;
} }
async function resolveCustomer(apiKey: string): Promise<Customer | null> { function buildCustomer(row: CustomerRow): Customer {
return {
id: row.id,
plan: row.plan,
active: Boolean(row.active),
email: row.email,
getCredential: <T extends PlatformCredentials>(platform: Platform) =>
getCredential<T>(row.id, platform),
};
}
export async function resolveCustomerByApiKey(apiKey: string): Promise<Customer | null> {
const cached = await redis.get(`customer:apikey:${apiKey}`); const cached = await redis.get(`customer:apikey:${apiKey}`);
if (cached) { if (cached) {
const base = JSON.parse(cached) as Omit<Customer, 'getCredential'>; const base = JSON.parse(cached) as Omit<CustomerRow, 'getCredential'>;
// Re-attach the credential loader (functions can't be cached) return buildCustomer(base as CustomerRow);
return {
...base,
getCredential: <T extends PlatformCredentials>(platform: Platform) =>
getCredential<T>(base.id, platform),
};
} }
const [rows] = await getPool().query<CustomerRow[]>( const [rows] = await getPool().query<CustomerRow[]>(
@@ -42,50 +49,80 @@ async function resolveCustomer(apiKey: string): Promise<Customer | null> {
); );
if (!rows.length) return null; if (!rows.length) return null;
const { id, plan, active, email } = rows[0]; const row = rows[0];
const customer: Customer = { await redis.setEx(`customer:apikey:${apiKey}`, 60, JSON.stringify(row));
id, return buildCustomer(row);
plan,
active: Boolean(active),
email,
getCredential: <T extends PlatformCredentials>(platform: Platform) =>
getCredential<T>(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;
} }
// Express middleware: resolve API key → Customer and attach to req.customer export async function resolveCustomerById(id: string): Promise<Customer | null> {
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<CustomerRow[]>(
'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( export async function meterMiddleware(
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction next: NextFunction
): Promise<void> { ): Promise<void> {
try { try {
// 1. Try API key
const apiKey = const apiKey =
(req.headers['x-api-key'] as string | undefined) || (req.headers['x-api-key'] as string | undefined) ||
(req.query.key as string | undefined); (req.query.key as string | undefined);
if (!apiKey) { if (apiKey) {
res.status(401).json({ error: 'Missing API key' }); 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; return;
} }
const customer = await resolveCustomer(apiKey); // 2. Try JWT session cookie
if (!customer) { const jwtCookie = req.cookies?.session;
res.status(401).json({ error: 'Invalid API key' }); if (jwtCookie) {
return; 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(401).json({ error: 'Missing API key or session' });
res.status(403).json({ error: 'Account suspended' });
return;
}
(req as Request & { customer: Customer }).customer = customer;
next();
} catch (err) { } catch (err) {
next(err); next(err);
} }

48
src/billing/usage.ts Normal file
View File

@@ -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<void> {
await getPool().query(
'INSERT INTO usage_logs (customer_id, platform, action) VALUES (?, ?, ?)',
[customerId, platform, action]
);
}
export async function getMonthlyUsage(customerId: string): Promise<number> {
const [rows] = await getPool().query<any[]>(
`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<Record<string, number>> {
const [rows] = await getPool().query<any[]>(
`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<string, number> = {};
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 };
}

View File

@@ -99,7 +99,7 @@ export async function getPosts(
const { accessToken, pageId } = await resolveCreds(args, customer); const { accessToken, pageId } = await resolveCreds(args, customer);
const limit = args.limit ?? 10; const limit = args.limit ?? 10;
const data = await fbRequest( 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 accessToken
); );
return (data.data ?? []).map((p: Record<string, unknown>) => ({ return (data.data ?? []).map((p: Record<string, unknown>) => ({

View File

@@ -6,7 +6,7 @@ const TIKTOK_API_BASE = 'https://open.tiktokapis.com/v2';
function getEnvToken(account: string): string { function getEnvToken(account: string): string {
const envKey = `TIKTOK_${account.toUpperCase()}_ACCESS_TOKEN`; 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<string> { async function resolveToken(args: { account?: string }, customer?: Customer): Promise<string> {
@@ -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) // Step 1: query creator info to get valid privacy levels (sandbox may not support PUBLIC_TO_EVERYONE)
const creatorInfo = await getCreatorInfo(args, customer); 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' ? 'PUBLIC_TO_EVERYONE'
: creatorInfo.privacy_level_options[0] ?? 'SELF_ONLY'; : options.includes('SELF_ONLY')
? 'SELF_ONLY'
: options[0] ?? 'SELF_ONLY';
// Step 2: initialise upload // Step 2: initialise upload
const init = await tiktokRequest('/post/publish/video/init/', accessToken, 'POST', { const init = await tiktokRequest('/post/publish/video/init/', accessToken, 'POST', {

View File

@@ -93,6 +93,7 @@ export async function initDatabase(): Promise<void> {
await ensureColumn(db, 'oauth_auth_codes', 'scope', 'TEXT NULL'); 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', 'TEXT NULL');
await ensureColumn(db, 'oauth_auth_codes', 'code_challenge_method', 'VARCHAR(20) 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(` await db.execute(`
CREATE TABLE IF NOT EXISTS oauth_tokens ( CREATE TABLE IF NOT EXISTS oauth_tokens (
@@ -112,10 +113,51 @@ export async function initDatabase(): Promise<void> {
plan ENUM('free', 'starter', 'growth', 'enterprise') DEFAULT 'free', plan ENUM('free', 'starter', 'growth', 'enterprise') DEFAULT 'free',
active BOOLEAN DEFAULT TRUE, active BOOLEAN DEFAULT TRUE,
email VARCHAR(255) NOT NULL, 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, 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 { } finally {
db.release(); db.release();
} }

View File

@@ -1,6 +1,7 @@
import 'dotenv/config'; import 'dotenv/config';
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import cookieParser from 'cookie-parser';
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.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 { getManifest, getOpenApiSpec, getOpenApiSpecMail, getOpenApiSpecSocial } from './manifest.js';
import { routeWhatsAppWebhook, registerWhatsAppNumber, type RoutedWebhookEvent } from './multitenancy/webhook-router.js'; import { routeWhatsAppWebhook, registerWhatsAppNumber, type RoutedWebhookEvent } from './multitenancy/webhook-router.js';
import { storeCredential, type Platform } from './multitenancy/credential-store.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 { import {
registerClient, registerClient,
getClient, getClient,
@@ -23,9 +24,13 @@ import {
validateAccessToken, validateAccessToken,
getAuthorizeHtml, getAuthorizeHtml,
} from './oauth.js'; } 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(); const app = express();
app.use(cookieParser());
app.use(cors({ app.use(cors({
origin: '*', origin: '*',
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
@@ -152,14 +157,14 @@ async function appendPilotRequestToVault(requestId: string, body: PilotRequestBo
const content = formatPilotRequestMarkdown(requestId, body, req); const content = formatPilotRequestMarkdown(requestId, body, req);
const dailyNotePath = `Daily Notes/${getEasternDateString()}.md`; const dailyNotePath = `Daily Notes/${getEasternDateString()}.md`;
await handleToolCall('obsidian_append_to_note', { await callTool(req, 'obsidian_append_to_note', {
path: 'SquareMCP/Pilot Requests.md', path: 'SquareMCP/Pilot Requests.md',
header: 'Pilot Requests', header: 'Pilot Requests',
content, content,
create_if_missing: true, create_if_missing: true,
}); });
await handleToolCall('obsidian_append_to_note', { await callTool(req, 'obsidian_append_to_note', {
path: dailyNotePath, path: dailyNotePath,
header: 'SquareMCP Pilot Requests', header: 'SquareMCP Pilot Requests',
content, content,
@@ -302,19 +307,48 @@ async function requireAuth(req: express.Request, res: express.Response, next: ex
// No API key configured = open access // No API key configured = open access
if (!API_KEY) return next(); 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); const apiKeyProvided = (req.headers['x-api-key'] as string | undefined) || (req.query.key as string | undefined);
if (apiKeyProvided === API_KEY) return next(); 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); const bearerToken = extractBearerToken(req);
if (bearerToken && await validateAccessToken(bearerToken)) return next(); 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( res.setHeader(
'WWW-Authenticate', 'WWW-Authenticate',
`Bearer realm="hermes", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"` `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) { } catch (err) {
next(err); next(err);
} }
@@ -717,7 +751,7 @@ app.post('/hermes-mcp/:linkSegment/:toolName', requireAuth, async (req, res) =>
const args = (req.body ?? {}) as Record<string, unknown>; const args = (req.body ?? {}) as Record<string, unknown>;
console.log(`[chatgpt-mcp] ${toolName}`, JSON.stringify(args).substring(0, 200)); console.log(`[chatgpt-mcp] ${toolName}`, JSON.stringify(args).substring(0, 200));
try { try {
const result = await handleToolCall(toolName, args); const result = await callTool(req, toolName, args);
const text = result.content[0].text; const text = result.content[0].text;
if (text.startsWith('Error:')) { if (text.startsWith('Error:')) {
res.status(400).json({ error: text.slice(7).trim() }); 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<string, unknown>; const args = req.query as Record<string, unknown>;
console.log(`[chatgpt-mcp] GET ${toolName}`, JSON.stringify(args).substring(0, 200)); console.log(`[chatgpt-mcp] GET ${toolName}`, JSON.stringify(args).substring(0, 200));
try { try {
const result = await handleToolCall(toolName, args); const result = await callTool(req, toolName, args);
const text = result.content[0].text; const text = result.content[0].text;
if (text.startsWith('Error:')) { if (text.startsWith('Error:')) {
res.status(400).json({ error: text.slice(7).trim() }); 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 tagsRaw = req.query.tags as string | undefined;
const tags = tagsRaw ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean) : undefined; const tags = tagsRaw ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean) : undefined;
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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; const path = req.query.path as string | undefined;
if (!path) { res.status(400).json({ error: 'path is required' }); return; } if (!path) { res.status(400).json({ error: 'path is required' }); return; }
try { try {
const result = await handleToolCall('obsidian_read_note', { path }); const result = await callTool(req, 'obsidian_read_note', { path });
res.json(parseToolResult(result)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
const msg = (err as Error).message; 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<string, unknown>; const { path, content, header, create_if_missing } = req.body as Record<string, unknown>;
if (!path || !content) { res.status(400).json({ error: 'path and content are required' }); return; } if (!path || !content) { res.status(400).json({ error: 'path and content are required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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<string, unknown>; const { path, content } = req.body as Record<string, unknown>;
if (!path || !content) { res.status(400).json({ error: 'path and content are required' }); return; } if (!path || !content) { res.status(400).json({ error: 'path and content are required' }); return; }
try { try {
const result = await handleToolCall('obsidian_update_note', { path, content }); const result = await callTool(req, 'obsidian_update_note', { path, content });
res.json(parseToolResult(result)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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 { try {
const result = await handleToolCall('obsidian_sync_status', {}); const result = await callTool(req, 'obsidian_sync_status', {});
res.json(parseToolResult(result)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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<string, unknown>; const { to, message, account } = req.body as Record<string, unknown>;
if (!to || !message) { res.status(400).json({ error: 'to and message are required' }); return; } if (!to || !message) { res.status(400).json({ error: 'to and message are required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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<string, unknown>; const { to, template_name, language, components, account } = req.body as Record<string, unknown>;
if (!to || !template_name) { res.status(400).json({ error: 'to and template_name are required' }); return; } if (!to || !template_name) { res.status(400).json({ error: 'to and template_name are required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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) => { app.get('/api/whatsapp/templates', requireAuth, async (req, res) => {
const account = req.query.account as string | undefined; const account = req.query.account as string | undefined;
try { try {
const result = await handleToolCall('whatsapp_list_templates', { account }); const result = await callTool(req, 'whatsapp_list_templates', { account });
res.json(parseToolResult(result)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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<string, string>;
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<any[]>('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<string, string>;
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 ─────────────────────────────── // ── Customer onboarding endpoints ───────────────────────────────
// Connect WhatsApp — called after customer enters their Meta credentials // 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 }); 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<string, string>;
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<string, string>;
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<string, unknown>) {
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<any[]>('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<any[]>(
'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 ───────────────────────────────────── // ── LinkedIn REST endpoints ─────────────────────────────────────
app.get('/api/linkedin/profile', requireAuth, async (req, res) => { app.get('/api/linkedin/profile', requireAuth, async (req, res) => {
const account = req.query.account as string | undefined; const account = req.query.account as string | undefined;
try { try {
const result = await handleToolCall('linkedin_get_profile', { account }); const result = await callTool(req, 'linkedin_get_profile', { account });
res.json(parseToolResult(result)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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<string, unknown>; const { text, visibility, account } = req.body as Record<string, unknown>;
if (!text) { res.status(400).json({ error: 'text is required' }); return; } if (!text) { res.status(400).json({ error: 'text is required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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) => { app.post('/api/linkedin/video', requireAuth, async (req, res) => {
try { try {
const { video_url, text, visibility, account } = req.body as Record<string, unknown>; const { video_url, text, visibility, account } = req.body as Record<string, unknown>;
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); res.json(result);
} catch (err) { } catch (err) {
res.status(500).json({ error: String(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) => { app.post('/api/linkedin/search-connections', requireAuth, async (req, res) => {
const { keywords, account } = req.body as Record<string, unknown>; const { keywords, account } = req.body as Record<string, unknown>;
try { try {
const result = await handleToolCall('linkedin_search_connections', { keywords, account }); const result = await callTool(req, 'linkedin_search_connections', { keywords, account });
res.json(parseToolResult(result)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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<string, unknown>; const { recipient_id, message, account } = req.body as Record<string, unknown>;
if (!recipient_id || !message) { res.status(400).json({ error: 'recipient_id and message are required' }); return; } if (!recipient_id || !message) { res.status(400).json({ error: 'recipient_id and message are required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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) => { app.get('/api/telegram/me', requireAuth, async (req, res) => {
const account = req.query.account as string | undefined; const account = req.query.account as string | undefined;
try { try {
const result = await handleToolCall('telegram_get_me', { account }); const result = await callTool(req, 'telegram_get_me', { account });
res.json(parseToolResult(result)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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<string, unknown>; const { chat_id, text, parse_mode, account } = req.body as Record<string, unknown>;
if (!chat_id || !text) { res.status(400).json({ error: 'chat_id and text are required' }); return; } if (!chat_id || !text) { res.status(400).json({ error: 'chat_id and text are required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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<string, unknown>; const { chat_id, photo, caption, account } = req.body as Record<string, unknown>;
if (!chat_id || !photo) { res.status(400).json({ error: 'chat_id and photo are required' }); return; } if (!chat_id || !photo) { res.status(400).json({ error: 'chat_id and photo are required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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 limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
const account = req.query.account as string | undefined; const account = req.query.account as string | undefined;
try { try {
const result = await handleToolCall('telegram_get_updates', { limit, account }); const result = await callTool(req, 'telegram_get_updates', { limit, account });
res.json(parseToolResult(result)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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; const account = req.query.account as string | undefined;
if (!chat_id) { res.status(400).json({ error: 'chat_id is required' }); return; } if (!chat_id) { res.status(400).json({ error: 'chat_id is required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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) => { app.get('/api/discord/me', requireAuth, async (req, res) => {
const account = req.query.account as string | undefined; const account = req.query.account as string | undefined;
try { try {
const result = await handleToolCall('discord_get_me', { account }); const result = await callTool(req, 'discord_get_me', { account });
res.json(parseToolResult(result)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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) => { app.get('/api/discord/guilds', requireAuth, async (req, res) => {
const account = req.query.account as string | undefined; const account = req.query.account as string | undefined;
try { try {
const result = await handleToolCall('discord_get_guilds', { account }); const result = await callTool(req, 'discord_get_guilds', { account });
res.json(parseToolResult(result)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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; const account = req.query.account as string | undefined;
if (!guild_id) { res.status(400).json({ error: 'guild_id is required' }); return; } if (!guild_id) { res.status(400).json({ error: 'guild_id is required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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<string, unknown>; const { channel_id, content, account } = req.body as Record<string, unknown>;
if (!channel_id || !content) { res.status(400).json({ error: 'channel_id and content are required' }); return; } if (!channel_id || !content) { res.status(400).json({ error: 'channel_id and content are required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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; const account = req.query.account as string | undefined;
if (!channel_id) { res.status(400).json({ error: 'channel_id is required' }); return; } if (!channel_id) { res.status(400).json({ error: 'channel_id is required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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) => { app.get('/api/instagram/profile', requireAuth, async (req, res) => {
const account = req.query.account as string | undefined; const account = req.query.account as string | undefined;
try { try {
const result = await handleToolCall('instagram_get_profile', { account }); const result = await callTool(req, 'instagram_get_profile', { account });
res.json(parseToolResult(result)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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 limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
const account = req.query.account as string | undefined; const account = req.query.account as string | undefined;
try { try {
const result = await handleToolCall('instagram_get_media', { limit, account }); const result = await callTool(req, 'instagram_get_media', { limit, account });
res.json(parseToolResult(result)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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<string, unknown>; const { image_url, caption, account } = req.body as Record<string, unknown>;
if (!image_url) { res.status(400).json({ error: 'image_url is required' }); return; } if (!image_url) { res.status(400).json({ error: 'image_url is required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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<string, unknown>; const { video_url, caption, account } = req.body as Record<string, unknown>;
if (!video_url) { res.status(400).json({ error: 'video_url is required' }); return; } if (!video_url) { res.status(400).json({ error: 'video_url is required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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; const account = req.query.account as string | undefined;
if (!query) { res.status(400).json({ error: 'query is required' }); return; } if (!query) { res.status(400).json({ error: 'query is required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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; const account = req.query.account as string | undefined;
if (!username) { res.status(400).json({ error: 'username is required' }); return; } if (!username) { res.status(400).json({ error: 'username is required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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; const account = req.query.account as string | undefined;
if (!username) { res.status(400).json({ error: 'username is required' }); return; } if (!username) { res.status(400).json({ error: 'username is required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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<string, unknown>; const { text, account } = req.body as Record<string, unknown>;
if (!text) { res.status(400).json({ error: 'text is required' }); return; } if (!text) { res.status(400).json({ error: 'text is required' }); return; }
try { try {
const result = await handleToolCall('twitter_create_tweet', { text, account }); const result = await callTool(req, 'twitter_create_tweet', { text, account });
res.json(parseToolResult(result)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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<string, unknown>; const { video_url, text, account } = req.body as Record<string, unknown>;
if (!video_url || !text) { res.status(400).json({ error: 'video_url and text are required' }); return; } if (!video_url || !text) { res.status(400).json({ error: 'video_url and text are required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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) => { app.get('/api/facebook/page', requireAuth, async (req, res) => {
const account = req.query.account as string | undefined; const account = req.query.account as string | undefined;
try { try {
const result = await handleToolCall('facebook_get_page', { account }); const result = await callTool(req, 'facebook_get_page', { account });
res.json(parseToolResult(result)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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 limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
const account = req.query.account as string | undefined; const account = req.query.account as string | undefined;
try { try {
const result = await handleToolCall('facebook_get_posts', { limit, account }); const result = await callTool(req, 'facebook_get_posts', { limit, account });
res.json(parseToolResult(result)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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<string, unknown>; const { message, link, account } = req.body as Record<string, unknown>;
if (!message) { res.status(400).json({ error: 'message is required' }); return; } if (!message) { res.status(400).json({ error: 'message is required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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<string, unknown>; const { image_url, caption, account } = req.body as Record<string, unknown>;
if (!image_url) { res.status(400).json({ error: 'image_url is required' }); return; } if (!image_url) { res.status(400).json({ error: 'image_url is required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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<string, unknown>; const { video_url, description, account } = req.body as Record<string, unknown>;
if (!video_url) { res.status(400).json({ error: 'video_url is required' }); return; } if (!video_url) { res.status(400).json({ error: 'video_url is required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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) => { app.get('/api/tiktok/profile', requireAuth, async (req, res) => {
const account = req.query.account as string | undefined; const account = req.query.account as string | undefined;
try { try {
const result = await handleToolCall('tiktok_get_profile', { account }); const result = await callTool(req, 'tiktok_get_profile', { account });
res.json(parseToolResult(result)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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) => { app.get('/api/tiktok/creator-info', requireAuth, async (req, res) => {
const account = req.query.account as string | undefined; const account = req.query.account as string | undefined;
try { try {
const result = await handleToolCall('tiktok_get_creator_info', { account }); const result = await callTool(req, 'tiktok_get_creator_info', { account });
res.json(parseToolResult(result)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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<string, unknown>; const { video_url, title, description, account } = req.body as Record<string, unknown>;
if (!video_url) { res.status(400).json({ error: 'video_url is required' }); return; } if (!video_url) { res.status(400).json({ error: 'video_url is required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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<string, unknown>; const { publish_id, account } = req.body as Record<string, unknown>;
if (!publish_id) { res.status(400).json({ error: 'publish_id is required' }); return; } if (!publish_id) { res.status(400).json({ error: 'publish_id is required' }); return; }
try { 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)); res.json(parseToolResult(result));
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); res.status(500).json({ error: (err as Error).message });

View File

@@ -1,5 +1,6 @@
import { Tool } from '@modelcontextprotocol/sdk/types.js'; import { Tool } from '@modelcontextprotocol/sdk/types.js';
import type { Customer } from './billing/middleware.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 { searchMessages, readMessage, getProfile, listFolders, type Account } from './imap.js';
import { sendEmail, createDraft } from './smtp.js'; import { sendEmail, createDraft } from './smtp.js';
import { searchNotes, getNote, appendToNote, updateNote, getSyncStatus } from './clients/obsidian.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)`); console.log(`[tool] ${name} OK (${Date.now() - t0}ms)`);
if (customer) {
const platform = name.split('_')[0];
recordUsage(customer.id, platform, name).catch(() => {});
}
return { return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
}; };

View File

@@ -1,54 +1,72 @@
# Remotion video # SquareMCP Remotion Workflow
<p align="center"> This directory is the canonical video-production workspace for SquareMCP marketing assets inside the `hermes-mcp` repo.
<a href="https://github.com/remotion-dev/logo">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/remotion-dev/logo/raw/main/animated-logo-banner-dark.apng">
<img alt="Animated Remotion Logo" src="https://github.com/remotion-dev/logo/raw/main/animated-logo-banner-light.gif">
</picture>
</a>
</p>
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 ```bash
npm i cd videos/remotion-demo
npm install
``` ```
**Start Preview** ## Preview in Studio
```console ```bash
cd videos/remotion-demo
npm run dev npm run dev
``` ```
**Render video** Remotion Studio will open on `http://localhost:3000`.
```console ## Render Commands
npx remotion render
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 ```bash
npx remotion upgrade 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

View File

@@ -1002,9 +1002,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1017,9 +1014,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1032,9 +1026,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1047,9 +1038,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1537,9 +1525,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1553,9 +1538,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1569,9 +1551,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1585,9 +1564,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1824,9 +1800,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1843,9 +1816,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1862,9 +1832,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1881,9 +1848,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3631,9 +3595,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3654,9 +3615,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3677,9 +3635,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3700,9 +3655,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [

View File

@@ -2,6 +2,14 @@ import "./index.css";
import { Composition } from "remotion"; import { Composition } from "remotion";
import { SquareMCPLinkedIn } from "./SquareMCPLinkedIn"; import { SquareMCPLinkedIn } from "./SquareMCPLinkedIn";
import { SquareMCPHeroLoop } from "./SquareMCPHeroLoop"; import { SquareMCPHeroLoop } from "./SquareMCPHeroLoop";
import {
SquareMCPTikTokCTA,
SquareMCPTikTokDemo,
SquareMCPTikTokFull,
SquareMCPTikTokHook,
SquareMCPTikTokProblem,
SquareMCPTikTokProof,
} from "./SquareMCPTikTok";
export const RemotionRoot = () => { export const RemotionRoot = () => {
return ( return (
@@ -22,6 +30,54 @@ export const RemotionRoot = () => {
width={1920} width={1920}
height={1080} height={1080}
/> />
<Composition
id="SquareMCPTikTokFull"
component={SquareMCPTikTokFull}
durationInFrames={30 * 30}
fps={30}
width={1080}
height={1920}
/>
<Composition
id="SquareMCPTikTokHook"
component={SquareMCPTikTokHook}
durationInFrames={3 * 30}
fps={30}
width={1080}
height={1920}
/>
<Composition
id="SquareMCPTikTokProblem"
component={SquareMCPTikTokProblem}
durationInFrames={5 * 30}
fps={30}
width={1080}
height={1920}
/>
<Composition
id="SquareMCPTikTokDemo"
component={SquareMCPTikTokDemo}
durationInFrames={12 * 30}
fps={30}
width={1080}
height={1920}
/>
<Composition
id="SquareMCPTikTokProof"
component={SquareMCPTikTokProof}
durationInFrames={5 * 30}
fps={30}
width={1080}
height={1920}
/>
<Composition
id="SquareMCPTikTokCTA"
component={SquareMCPTikTokCTA}
durationInFrames={5 * 30}
fps={30}
width={1080}
height={1920}
/>
</> </>
); );
}; };

View File

@@ -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 (
<AbsoluteFill>
<TikTokBackground />
{children}
</AbsoluteFill>
);
};
export const SquareMCPTikTokFull = () => {
const { fps } = useVideoConfig();
return (
<TikTokShell>
<Sequence durationInFrames={3 * fps}>
<TikTokHook />
</Sequence>
<Sequence from={3 * fps} durationInFrames={5 * fps}>
<TikTokProblem />
</Sequence>
<Sequence from={8 * fps} durationInFrames={12 * fps}>
<TikTokDemo />
</Sequence>
<Sequence from={20 * fps} durationInFrames={5 * fps}>
<TikTokProof />
</Sequence>
<Sequence from={25 * fps} durationInFrames={5 * fps}>
<TikTokCTA />
</Sequence>
</TikTokShell>
);
};
export const SquareMCPTikTokHook = () => {
return (
<TikTokShell>
<TikTokHook />
</TikTokShell>
);
};
export const SquareMCPTikTokProblem = () => {
return (
<TikTokShell>
<TikTokProblem />
</TikTokShell>
);
};
export const SquareMCPTikTokDemo = () => {
return (
<TikTokShell>
<TikTokDemo />
</TikTokShell>
);
};
export const SquareMCPTikTokProof = () => {
return (
<TikTokShell>
<TikTokProof />
</TikTokShell>
);
};
export const SquareMCPTikTokCTA = () => {
return (
<TikTokShell>
<TikTokCTA />
</TikTokShell>
);
};

View File

@@ -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 (
<AbsoluteFill
style={{
background: `radial-gradient(circle at ${20 + orbit * 45}% ${18 + orbit * 30}%, rgba(125, 182, 255, 0.18), transparent 28%), radial-gradient(circle at 80% 78%, rgba(14, 99, 246, 0.22), transparent 30%), linear-gradient(180deg, #070812 0%, ${COLORS.bg} 100%)`,
}}
>
<div
style={{
position: "absolute",
inset: 0,
backgroundImage:
"linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px)",
backgroundSize: "60px 60px",
maskImage: "linear-gradient(180deg, rgba(0,0,0,0.65), rgba(0,0,0,0.95))",
}}
/>
</AbsoluteFill>
);
};

View File

@@ -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 (
<AbsoluteFill style={{ alignItems: "center", justifyContent: "center" }}>
<div
style={{
position: "absolute",
width: 520,
height: 520,
borderRadius: 999,
background: `radial-gradient(circle, rgba(14,99,246,${glow}) 0%, rgba(14,99,246,0) 68%)`,
filter: "blur(12px)",
}}
/>
<div
style={{
opacity: logoIn,
transform: `scale(${0.9 + logoIn * 0.1}) translateY(${(1 - logoIn) * 50}px)`,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Img src={staticFile("squaremcp-logo.svg")} style={{ width: 240, height: 240 }} />
<div
style={{
marginTop: 26,
fontFamily: FONT,
fontSize: 82,
fontWeight: 800,
color: COLORS.text,
letterSpacing: -2.2,
}}
>
SquareMCP
</div>
<div
style={{
marginTop: 12,
fontFamily: FONT,
fontSize: 34,
fontWeight: 600,
color: COLORS.accentLight,
letterSpacing: -0.6,
}}
>
squaremcp.com
</div>
<div
style={{
marginTop: 22,
fontFamily: FONT,
fontSize: 24,
color: COLORS.textSecondary,
}}
>
Early access now open
</div>
</div>
</AbsoluteFill>
);
};

View File

@@ -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 (
<AbsoluteFill style={{ padding: 58 }}>
<div style={{ fontFamily: FONT, color: COLORS.text, fontSize: 68, fontWeight: 800, lineHeight: 1, marginTop: 48 }}>
One prompt.
<br />
Real execution.
</div>
<div
style={{
marginTop: 42,
borderRadius: 36,
border: `1px solid ${COLORS.borderBlue}`,
background: "rgba(9, 12, 22, 0.94)",
padding: 30,
opacity: chatIn,
transform: `translateY(${(1 - chatIn) * 60}px)`,
}}
>
<div style={{ fontFamily: FONT, color: COLORS.textSecondary, fontSize: 22, marginBottom: 18 }}>Chat</div>
<div
style={{
marginLeft: "auto",
maxWidth: 760,
borderRadius: 28,
background: "linear-gradient(135deg, rgba(14,99,246,0.95), rgba(125,182,255,0.9))",
padding: "24px 26px",
color: "#08111f",
fontFamily: FONT,
fontSize: 34,
fontWeight: 700,
lineHeight: 1.25,
}}
>
Post my launch video to LinkedIn, X, and Instagram.
</div>
</div>
<div
style={{
marginTop: 28,
borderRadius: 36,
border: "1px solid rgba(34, 197, 94, 0.24)",
background: "rgba(5, 14, 10, 0.9)",
padding: 30,
opacity: jsonIn,
transform: `translateY(${(1 - jsonIn) * 60}px)`,
}}
>
<div style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", color: "#86efac", fontSize: 22, marginBottom: 18 }}>
response.json
</div>
{[
'{',
' "status": "success",',
' "published": ["linkedin", "x", "instagram"],',
' "duration_seconds": 12,',
' "urls": 3',
'}',
].map((line) => (
<div
key={line}
style={{
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
color: line.includes("success") ? "#86efac" : "#dbeafe",
fontSize: 24,
lineHeight: 1.55,
}}
>
{line}
</div>
))}
</div>
<div
style={{
marginTop: 28,
display: "grid",
gridTemplateColumns: "1fr",
gap: 16,
opacity: chipsIn,
transform: `scale(${0.96 + chipsIn * 0.04})`,
}}
>
{["linkedin.com/post/7459...", "x.com/squaremcp/status/...", "instagram.com/reel/C..."].map((url) => (
<div
key={url}
style={{
borderRadius: 22,
padding: "18px 22px",
background: "rgba(255,255,255,0.06)",
border: `1px solid ${COLORS.borderBlue}`,
display: "flex",
alignItems: "center",
gap: 16,
boxShadow: `0 0 24px rgba(34, 197, 94, ${0.12 * pulse})`,
}}
>
<div
style={{
width: 18,
height: 18,
borderRadius: 999,
background: "#22c55e",
}}
/>
<div style={{ fontFamily: FONT, color: COLORS.text, fontSize: 24, fontWeight: 600 }}>{url}</div>
</div>
))}
</div>
</AbsoluteFill>
);
};

View File

@@ -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 (
<AbsoluteFill style={{ padding: 72 }}>
<div
style={{
marginTop: 120,
borderRadius: 32,
border: `1px solid ${COLORS.borderBlue}`,
background: "rgba(7, 8, 18, 0.92)",
boxShadow: `0 20px 60px ${COLORS.accentGlow}`,
padding: "28px 32px",
opacity: terminalIn,
transform: `translateY(${(1 - terminalIn) * 80}px)`,
}}
>
<div style={{ display: "flex", gap: 12, marginBottom: 26 }}>
{["#ff5f57", "#febc2e", "#28c840"].map((dot) => (
<div key={dot} style={{ width: 14, height: 14, borderRadius: 999, background: dot }} />
))}
</div>
<div
style={{
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontWeight: 700,
fontSize: 44,
color: COLORS.accentLight,
letterSpacing: -1.2,
}}
>
{"> connect squaremcp"}
<span style={{ color: COLORS.text, opacity: (frame % 20) < 10 ? 1 : 0 }}>|</span>
</div>
</div>
<div
style={{
marginTop: 48,
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 18,
opacity: gridIn,
transform: `scale(${0.9 + gridIn * 0.1})`,
}}
>
{TOOL_LABELS.map((label, index) => (
<div
key={label}
style={{
borderRadius: 24,
border: `1px solid ${COLORS.borderBlue}`,
background: index % 2 === 0 ? "rgba(18, 18, 31, 0.95)" : "rgba(10, 17, 38, 0.92)",
padding: "26px 16px",
minHeight: 112,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: COLORS.text,
fontFamily: FONT,
fontWeight: 700,
fontSize: 28,
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.03)",
}}
>
{label}
</div>
))}
</div>
<div
style={{
marginTop: "auto",
marginBottom: 68,
fontFamily: FONT,
fontSize: 54,
fontWeight: 800,
color: COLORS.text,
lineHeight: 1.05,
letterSpacing: -1.8,
opacity: captionIn,
transform: `translateY(${(1 - captionIn) * 40}px)`,
}}
>
One API key.
<br />
One workflow layer.
</div>
</AbsoluteFill>
);
};

View File

@@ -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 (
<AbsoluteFill style={{ padding: 60, flexDirection: "column" }}>
<div
style={{
fontFamily: FONT,
fontSize: 70,
fontWeight: 800,
color: COLORS.text,
letterSpacing: -2,
lineHeight: 1,
marginTop: 44,
marginBottom: 42,
}}
>
8 tabs vs
<br />
1 gateway
</div>
<div style={{ display: "flex", flex: 1, gap: 28 }}>
<div
style={{
flex: 1,
opacity: leftIn,
transform: `translateX(${(1 - leftIn) * -80}px) rotate(${-4 + (1 - leftIn) * -8}deg)`,
}}
>
<div
style={{
borderRadius: 32,
border: `1px solid rgba(239, 68, 68, 0.4)`,
background: "rgba(35, 10, 16, 0.88)",
padding: 28,
height: "100%",
}}
>
<div style={{ fontFamily: FONT, color: "#fca5a5", fontSize: 30, fontWeight: 700, marginBottom: 20 }}>
Before
</div>
<div style={{ display: "grid", gap: 16 }}>
{LEFT_APPS.map((app, index) => (
<div
key={app}
style={{
borderRadius: 20,
padding: "18px 20px",
background: `rgba(255,255,255,${0.06 + index * 0.01})`,
border: "1px solid rgba(255,255,255,0.06)",
fontFamily: FONT,
color: COLORS.text,
fontSize: 28,
fontWeight: 600,
transform: `translateX(${index % 2 === 0 ? -12 : 18}px)`,
}}
>
{app}
</div>
))}
</div>
</div>
</div>
<div
style={{
width: 6,
borderRadius: 999,
background: `linear-gradient(180deg, rgba(255,255,255,0.05), rgba(14, 99, 246, ${divider}), rgba(255,255,255,0.05))`,
}}
/>
<div
style={{
flex: 1,
opacity: rightIn,
transform: `translateX(${(1 - rightIn) * 80}px)`,
}}
>
<div
style={{
borderRadius: 32,
border: `1px solid ${COLORS.borderBlue}`,
background: "rgba(9, 16, 36, 0.92)",
padding: 28,
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: 20,
}}
>
<div style={{ fontFamily: FONT, color: COLORS.accentLight, fontSize: 30, fontWeight: 700 }}>
After
</div>
{["AI agent", "SquareMCP", "Connected tools"].map((item, index) => (
<div
key={item}
style={{
borderRadius: 22,
padding: "24px 22px",
background: index === 1 ? "rgba(14, 99, 246, 0.22)" : "rgba(255,255,255,0.05)",
border: `1px solid ${index === 1 ? COLORS.borderBlue : "rgba(255,255,255,0.08)"}`,
fontFamily: FONT,
color: COLORS.text,
fontSize: 30,
fontWeight: index === 1 ? 800 : 600,
textAlign: "center",
}}
>
{item}
</div>
))}
<div style={{ fontFamily: FONT, color: COLORS.textSecondary, fontSize: 24, lineHeight: 1.35 }}>
One entry point for auth, permissions, posting, notes, and internal systems.
</div>
</div>
</div>
</div>
</AbsoluteFill>
);
};

View File

@@ -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 (
<AbsoluteFill style={{ padding: 60 }}>
<div style={{ fontFamily: FONT, fontSize: 62, fontWeight: 800, color: COLORS.text, letterSpacing: -1.8, marginTop: 50 }}>
Publish once.
<br />
Watch it land.
</div>
<div
style={{
marginTop: 52,
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 22,
opacity: inView,
transform: `scale(${0.92 + inView * 0.08})`,
}}
>
{CHANNELS.map((channel, index) => (
<div
key={channel.name}
style={{
borderRadius: 30,
border: `1px solid ${COLORS.borderBlue}`,
background: channel.tint,
minHeight: 260,
padding: 24,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
transform: `translateY(${index % 2 === 0 ? -8 : 12}px)`,
}}
>
<div style={{ fontFamily: FONT, color: COLORS.text, fontSize: 30, fontWeight: 700 }}>{channel.name}</div>
<div style={{ fontFamily: FONT, color: "#86efac", fontSize: 26, fontWeight: 800 }}>LIVE</div>
<div style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", color: COLORS.textSecondary, fontSize: 22 }}>
{channel.time}
</div>
</div>
))}
</div>
</AbsoluteFill>
);
};