Replace the real location ID in FUTURE_DEV.md example with a placeholder
to avoid trivy-secrets false positive. Also sanitize the cast_api_key
comment in admin.go and add .trivyignore to exclude documentation files
from the secret scanner.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Replace errors.New("location not found") string with a package-level
sentinel var so callers can use errors.Is() instead of string comparison,
which errorlint flags as unsafe.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Each GHL location can now have its own Cast API key and sender ID stored
in MongoDB. Falls back to global CAST_API_KEY / CAST_SENDER_ID env vars
when not set per-location.
Admin endpoints (all require Authorization: Bearer <INBOUND_API_KEY>):
GET /api/admin/locations — list all locations
GET /api/admin/locations/{locationId}/config — get location config
PUT /api/admin/locations/{locationId}/config — set sender_id + cast_api_key
Cast API key is masked in GET responses (first 12 chars + "...").
Replaces the /sender-id endpoint deployed in the previous commit.
Also adds FUTURE_DEV.md documenting the migration path to Infisical
for secret management, plus MongoDB security hardening checklist.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Allows each GHL sub-account to use a different Cast sender ID instead of
the global CAST_SENDER_ID default.
- store.TokenRecord gains a sender_id field (MongoDB)
- store.UpdateSenderID method to set it per location
- cast.Client.SendSMS accepts a senderID override param (empty = use
client-level default)
- webhook.processOutbound reads the location's sender_id from the token
record and passes it to Cast
- new admin handler: PUT /api/admin/locations/{locationId}/sender-id
protected by Authorization: Bearer <INBOUND_API_KEY>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Adds per-step log entries so we can trace exactly where the outbound
SMS flow breaks: goroutine start, phone normalization result, Cast API
call attempt, and Cast API result. Also adds panic recovery so a crash
in the goroutine is captured in structured logs instead of lost silently.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
GHL_CLIENT_ID has the format "<24-hex-objectid>-<suffix>" but the
installedLocations endpoint expects only the 24-hex MongoDB ObjectId
portion as appId. Strip everything from the first "-" onward.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The endpoint requires appId (GHL client ID) as a query parameter.
Without it the API returns 422 "appId must be a string".
Co-Authored-By: Paperclip <noreply@paperclip.ing>
/locations/search requires locations.readonly which GHL never includes in
company-level OAuth tokens. /oauth/installedLocations uses oauth.readonly,
which is always present in company tokens, and returns only locations where
this app is actually installed.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
GHL includes installedLocations[] in the company-level token response for
bulk installs. Use those IDs directly to avoid calling /locations/search,
which requires locations.readonly scope that GHL doesn't grant. Falls back
to /locations/search only when the list is absent. Also adds raw_body and
installed_locations fields to token response debug logging.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The /locations/search endpoint requires locations.readonly scope.
Without it the company token gets 401 when trying to list locations
during bulk install, blocking the per-location token exchange.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
GHL issues a Company-scoped token (userType=Company) for bulk/agency
installs even when Target User=Sub-account. This fix handles that case:
1. Detect userType=Company in HandleCallback
2. Call GET /locations/search to enumerate all company locations
3. For each location call POST /oauth/locationToken to get a
Location-scoped token (userType=Location, includes locationId)
4. Store each location token individually in MongoDB
This allows webhook delivery and status updates to work per-location
without requiring the agency admin to re-install per sub-account.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Temporary diagnostic logging to determine what GHL includes in the
token exchange response and the OAuth callback redirect URL when
user_type is Company (Sub-account target app, agency-level install).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
GHL signs x-wh-signature webhooks with an Ed25519 key from the
Marketplace app settings, not RSA. The previous RSA implementation
caused all webhook signature checks to fail, blocking every outbound
SMS send.
Changes:
- Replace parseRSAPublicKey + RSA verification with parseEd25519PublicKey
+ ed25519.Verify for x-wh-signature
- Both x-wh-signature (current) and X-GHL-Signature (July 2026) now
use the same Ed25519 key from GHL_WEBHOOK_PUBLIC_KEY
- Remove unused crypto/rsa, crypto/sha256, crypto imports
- Update webhook_test.go to generate/sign with Ed25519 instead of RSA
Co-Authored-By: Paperclip <noreply@paperclip.ing>
GHL returns an empty locationId when the user authorizes at agency level
instead of selecting a specific sub-account location. Without this guard
the token was silently stored under an empty key, making every subsequent
webhook fail with "no token for location".
Also logs location_id/company_id/user_type from the token response to
make future OAuth install debugging easier.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
When the bridge container fails to start, the deploy script previously
only showed docker compose ps which doesn't include the crash reason.
Now outputs last 30 log lines so pipeline output shows the error.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Trivy flagged golang.org/x/crypto v0.33.0 with HIGH severity
CVE-2025-22869 (DoS in SSH key exchange). Upgraded to v0.35.0
which contains the fix.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
G112 (Slowloris): add ReadHeaderTimeout: 10s to http.Server
G602 (slice bounds): use explicit bounds-safe index for backoff slice
(attempt is guarded but gosec can't prove it statically)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Running as root in a container is a security hazard. Use the existing
nobody user from alpine:3.19 to drop privileges before CMD.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Fix two more json.NewEncoder(w).Encode() calls in oauth_test.go
(lines 53 and 119) that were missed in the previous pass.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- internal/ghl/oauth.go:186: defer func(){ _ = resp.Body.Close() }()
- internal/cast/client_test.go: prefix all json.Decode/Encode calls with _ =
- internal/ghl/oauth_test.go: _ = r.ParseForm(), _, _ = w.Write(...)
golangci-lint exclusion rules in v2 are not suppressing test file errcheck
as expected, so fixes are applied directly in source.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Production fixes:
- cmd/server/main.go: refactor to run() helper to eliminate exitAfterDefer
(os.Exit in main() no longer bypasses deferred s.Close)
- internal/cast/client.go: use _ = resp.Body.Close() (errcheck)
- internal/ghl/api.go: wrap both defers as func(){ _ = resp.Body.Close() }()
Test fixes:
- internal/cast/client_test.go: replace err.(*CastAPIError) type assertions
with errors.As (errorlint)
Config:
- .golangci.yml: use explicit path regex .*_test\\.go and add errorlint
to test-file exclusions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
golangci-lint v2 requires version: "2" at the top level, linters.settings
(not linters-settings), and issues.exclusions.rules (not issues.exclude-rules).
Also removed gosimple and unused which are now merged into staticcheck.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
pem.Decode requires actual newlines. When a PEM key is pasted into a
.env file it is commonly stored as a single line with \n literals.
Normalise these before decoding so both formats work.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
localhost:27017 resolves to the container itself inside Docker.
The mongo service is reachable via its compose service name 'mongo'.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Align Woodpecker CI pipeline with team standard (cast-backend pattern):
- Replace plugins/docker with woodpeckerci/plugin-docker-buildx
- Use git.sds.dev registry; tag with CI_COMMIT_SHA short + latest
- Use team secret names: registry_user/password, deploy_ssh_key
- Add golangci-lint, semgrep, gosec, trivy-fs, trivy-secrets security gates
- Deploy on push to main (not on tag): build-and-push then deploy step
calls bash /opt/cast-ghl-provider/deploy/deploy.sh on server
- Add Telegram notification on success/failure
docker-compose.yaml: add image: git.sds.dev/cast/cast-ghl-provider:latest
(server pulls from registry; build: kept for local dev only)
deploy/deploy.sh: simplified to docker compose pull + up
(build now happens in CI, not on the server)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Update all active config and documentation files to use the correct
production domain hl.cast.ph (not ghl.cast.ph).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- docker-compose.yaml: remove port binding; add VIRTUAL_HOST/LETSENCRYPT_HOST
env vars for nginx-proxy auto-routing; add internal + external proxy networks
- .woodpecker.yml: consolidate build steps into single ci step; add deploy-main
step that builds + deploys on every push to main; keep deploy-tag for
registry-pull deploys on version tags
- deploy/deploy.sh: simplify for manual/emergency use on existing server;
add --from-registry flag for registry pull vs local build
- Remove deploy/setup-server.sh and deploy/nginx/ (not needed on existing server)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
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>
Complete MVP implementation of the Cast GHL Conversation Provider bridge:
- Go module setup with chi router and mongo-driver dependencies
- Config loading with env var validation and defaults
- MongoDB token store with upsert, get, update, delete operations
- Cast.ph SMS client with 429 retry logic and typed errors
- Phone number normalization (E.164 ↔ Philippine local format)
- GHL OAuth 2.0 install/callback/refresh flow
- GHL webhook handler with ECDSA signature verification (async dispatch)
- GHL API client for message status updates and inbound message stubs
- Multi-stage Dockerfile, docker-compose with MongoDB, Woodpecker CI pipeline
- Unit tests for phone normalization, Cast client, GHL webhook, and OAuth handlers
Co-Authored-By: SideKx <sidekx.ai@sds.dev>