cast-ghl-plugin/CLAUDE.md
Head of Product & Engineering a2826a3da7
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix: switch webhook signature verification from ECDSA to RSA-PKCS1v15+SHA-256
GHL uses RSA + SHA-256 for x-wh-signature, not ECDSA P-256 as documented
in the original task files. Also adds forward-compatible Ed25519 support
for X-GHL-Signature (GHL migration scheduled July 2026): handler checks
X-GHL-Signature first, falls back to x-wh-signature.

- webhook.go: replace ecdsa.VerifyASN1 with rsa.VerifyPKCS1v15; add
  verifyEd25519 + verifyIncomingSignature dispatch; update struct fields
- webhook_test.go: regenerate test keys as RSA-2048, sign with PKCS1v15
- CLAUDE.md: correct crypto stack and key implementation notes
- .env.example: clarify GHL_WEBHOOK_PUBLIC_KEY is a static RSA key from docs

Co-Authored-By: SideKx <sidekx.ai@sds.dev>
2026-04-05 01:15:56 +02:00

159 lines
7.1 KiB
Markdown

# cast-ghl-provider — Claude Code Project Instructions
## What This Is
`cast-ghl-provider` is a Go HTTP service that acts as a GHL (GoHighLevel) Marketplace SMS Conversation Provider. It bridges GHL's outbound SMS webhooks to Cast.ph's SMS API, replacing Twilio/LC-Phone as the default SMS provider for GHL sub-accounts.
## Stack
- **Language:** Go 1.22+
- **HTTP:** `net/http` (stdlib) + `chi` router (lightweight)
- **Database:** MongoDB (OAuth token storage)
- **Mongo driver:** `go.mongodb.org/mongo-driver/v2`
- **HTTP client:** `net/http` (stdlib, no external HTTP client)
- **JSON:** `encoding/json` (stdlib)
- **Crypto:** `crypto/rsa` + `crypto/sha256` (webhook sig: RSA-PKCS1v15+SHA-256); `crypto/ed25519` (X-GHL-Signature fallback, active July 2026+)
- **Config:** Environment variables only (no config files)
- **Deploy:** Docker + Docker Compose on Vultr
## External APIs
### Cast.ph SMS API
**Base URL:** `https://api.cast.ph`
**Auth:** `X-API-Key: cast_<64-hex-chars>`
**Full docs:** `CAST_API_REFERENCE.md` in repo root
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/sms/send` | POST | Send outbound SMS |
Request: `{ "to": "09171234567", "message": "text", "sender_id": "CAST" }`
Response: `{ "success": true, "message_id": "abc123", "parts": 1 }`
Key behaviors:
- Phone numbers: 11-digit Philippine format (`09XXXXXXXXX`)
- Message max: 450 characters (3 SMS parts)
- Rate limit: 30 req/s, burst 50, 429 with Retry-After
- Errors: `{ "success": false, "error": "..." }`
### GHL API
**Base URL:** `https://services.leadconnectorhq.com`
**Auth:** `Authorization: Bearer <access_token>` (OAuth 2.0)
**Provider docs:** `GHL_API_REFERENCE.md` in repo root
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/oauth/token` | POST | Exchange auth code / refresh token |
| `/conversations/messages/{messageId}/status` | PUT | Update outbound message status |
| `/conversations/messages/inbound` | POST | Post inbound SMS to GHL (Phase 2) |
### GHL Webhook (inbound TO our service)
GHL sends `ProviderOutboundMessage` to our delivery URL:
```json
{
"contactId": "...",
"locationId": "...",
"messageId": "...",
"type": "SMS",
"phone": "+639171234567",
"message": "text to send",
"attachments": [],
"userId": "..."
}
```
Verified via `x-wh-signature` header using RSA-PKCS1v15 + SHA-256.
Future: `X-GHL-Signature` header using Ed25519 (GHL migration July 2026).
## Project Structure
```
cast-ghl-provider/
├── cmd/
│ └── server/
│ └── main.go # Entry: config, routes, graceful shutdown
├── internal/
│ ├── config/
│ │ └── config.go # Env var loading + validation
│ ├── ghl/
│ │ ├── oauth.go # OAuth install, callback, token refresh
│ │ ├── webhook.go # Outbound webhook handler + sig verify
│ │ ├── api.go # GHL API client (status update, inbound)
│ │ └── types.go # GHL types
│ ├── cast/
│ │ ├── client.go # Cast API HTTP client
│ │ └── types.go # Cast types
│ ├── phone/
│ │ └── normalize.go # E.164 ↔ PH local conversion
│ └── store/
│ └── mongo.go # MongoDB token/session storage
├── Dockerfile
├── docker-compose.yaml
├── .env.example
├── go.mod
├── CAST_API_REFERENCE.md
├── GHL_API_REFERENCE.md
├── CLAUDE.md # THIS FILE
├── .claude/tasks/ # Sequential dev tasks
└── README.md
```
## Conventions
- **One package per concern** — `ghl/`, `cast/`, `phone/`, `store/`, `config/`
- **No global state** — pass dependencies via struct fields (dependency injection without a framework)
- **Error handling:** Return `error`, never panic. Wrap with `fmt.Errorf("context: %w", err)`
- **Logging:** `log/slog` (structured logging, stdlib). JSON format in production.
- **HTTP handlers:** `func(w http.ResponseWriter, r *http.Request)` — standard `net/http`
- **Context:** Pass `context.Context` through all external calls (HTTP, MongoDB)
- **Tests:** `_test.go` files alongside source. Use `httptest` for HTTP mocks.
- **Naming:** Follow Go conventions — exported types are PascalCase, packages are lowercase single-word
## Config (env vars)
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `PORT` | No | `3002` | Server listen port |
| `BASE_URL` | Yes | — | Public URL (e.g. `https://ghl.cast.ph`) |
| `GHL_CLIENT_ID` | Yes | — | GHL Marketplace app client ID |
| `GHL_CLIENT_SECRET` | Yes | — | GHL Marketplace app client secret |
| `GHL_WEBHOOK_PUBLIC_KEY` | Yes | — | PEM-encoded ECDSA public key for webhook sig |
| `GHL_CONVERSATION_PROVIDER_ID` | Yes | — | Conversation provider ID from GHL app |
| `CAST_API_KEY` | Yes | — | Cast.ph API key |
| `CAST_API_URL` | No | `https://api.cast.ph` | Cast API base URL |
| `CAST_SENDER_ID` | No | — | Default sender ID (uses account default if empty) |
| `MONGO_URI` | Yes | — | MongoDB connection string |
| `INBOUND_API_KEY` | No | — | Shared secret for future inbound webhook auth |
Validated at startup. Missing required vars → log error + os.Exit(1).
## Routes
| Method | Path | Handler | Purpose |
|--------|------|---------|---------|
| `GET` | `/health` | healthCheck | Health check |
| `GET` | `/install` | ghl.HandleInstall | Start OAuth flow |
| `GET` | `/oauth-callback` | ghl.HandleCallback | OAuth redirect handler |
| `POST` | `/api/ghl/v1/webhook/messages` | ghl.HandleWebhook | Outbound SMS webhook |
| `POST` | `/api/ghl/v1/inbound-sms` | ghl.HandleInbound | Inbound SMS (Phase 2) |
## Development Workflow
1. Work through `.claude/tasks/` in order (01 → 10)
2. `go build ./cmd/server/` after each task
3. `go test ./...` for tests
4. `go vet ./...` for static analysis
5. Docker: `docker compose up --build` for full stack
## Key Implementation Notes
1. **Webhook signature verification is mandatory** — GHL sends `x-wh-signature` on every webhook. Verify with RSA-PKCS1v15 + SHA-256 using the static RSA public key from GHL docs (set in `GHL_WEBHOOK_PUBLIC_KEY` env var). From July 2026, also support `X-GHL-Signature` (Ed25519). The handler checks `X-GHL-Signature` first and falls back to `x-wh-signature`.
2. **OAuth tokens are per-location** — store `locationId``{ access_token, refresh_token, expires_at }` in MongoDB. Refresh before expiry.
3. **Phone normalization is critical** — GHL sends E.164 (`+639XXXXXXXXX`), Cast expects `09XXXXXXXXX`. Get this wrong = messages fail.
4. **Status updates must use the provider's token** — only the conversation provider marketplace app tokens can update message status.
5. **Respond 200 to webhook immediately** — process the SMS send asynchronously (goroutine) so GHL doesn't timeout waiting.
6. **Cast API has no inbound webhook yet** — inbound SMS is Phase 2, after Cast SIM gateway adds webhook support.
7. **GHL API base is `services.leadconnectorhq.com`** — not `rest.gohighlevel.com` (that's v1, deprecated).