feat: per-location Cast API key and unified admin config API
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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>
This commit is contained in:
parent
5312eb0ca2
commit
9995027093
75
FUTURE_DEV.md
Normal file
75
FUTURE_DEV.md
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
# Future Development Notes
|
||||||
|
|
||||||
|
This document tracks planned improvements, technical debt with business impact, and migration paths
|
||||||
|
for features currently implemented with known limitations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrade: Per-location Cast API keys from MongoDB → Infisical
|
||||||
|
|
||||||
|
### Current state (Option A)
|
||||||
|
|
||||||
|
Per-location Cast API keys (`cast_api_key`) and sender IDs (`sender_id`) are stored in MongoDB
|
||||||
|
alongside OAuth tokens in the `cast-ghl.oauth_tokens` collection.
|
||||||
|
|
||||||
|
**Limitation:** Secret material (Cast API keys) stored in the same database as operational data.
|
||||||
|
If MongoDB is compromised, all per-location Cast credentials are exposed. MongoDB auth and TLS
|
||||||
|
mitigate this but do not eliminate the risk.
|
||||||
|
|
||||||
|
### Target state (Option C)
|
||||||
|
|
||||||
|
Use **[Infisical](https://github.com/Infisical/infisical)** as the secrets backend for per-location
|
||||||
|
Cast API keys. Infisical is open-source, self-hostable, and provides secret versioning, audit logs,
|
||||||
|
access policies, and dynamic secrets.
|
||||||
|
|
||||||
|
### Migration plan
|
||||||
|
|
||||||
|
#### 1. Deploy Infisical (self-hosted or Infisical Cloud)
|
||||||
|
- Recommended: self-host on the same Vultr instance or a dedicated secrets VM
|
||||||
|
- Create a project: `cast-ghl-provider`
|
||||||
|
- Create an environment: `production`
|
||||||
|
- Create a machine identity (service account) for the bridge service
|
||||||
|
|
||||||
|
#### 2. Secret naming convention
|
||||||
|
Store each location's API key as a secret named:
|
||||||
|
```
|
||||||
|
CAST_API_KEY_<locationId>
|
||||||
|
```
|
||||||
|
Example: `CAST_API_KEY_q5LZDBHiJ9BsY9Vge5De`
|
||||||
|
|
||||||
|
#### 3. Code changes in the bridge
|
||||||
|
- Add `INFISICAL_CLIENT_ID` and `INFISICAL_CLIENT_SECRET` env vars to config
|
||||||
|
- Add Infisical Go SDK: `github.com/infisical/go-sdk`
|
||||||
|
- Create `internal/secrets/infisical.go` — wraps the SDK with a `GetLocationAPIKey(locationID) (string, error)` method and an in-process cache (TTL ~60s to avoid hammering the API)
|
||||||
|
- In `processOutbound`: call `secrets.GetLocationAPIKey(locationID)` instead of reading from the token record
|
||||||
|
- The `cast_api_key` field in `TokenRecord` can be kept as a fallback during migration, then removed
|
||||||
|
|
||||||
|
#### 4. Admin API changes
|
||||||
|
- `PUT /api/admin/locations/{locationId}/config` should write the API key to Infisical (not MongoDB)
|
||||||
|
- `GET` endpoints should read from Infisical and mask the returned value
|
||||||
|
|
||||||
|
#### 5. Migration of existing keys
|
||||||
|
1. For each location with a `cast_api_key` in MongoDB, write it to Infisical via the admin API
|
||||||
|
2. Verify the bridge reads the key correctly from Infisical
|
||||||
|
3. Clear `cast_api_key` from MongoDB records
|
||||||
|
4. Remove the MongoDB field once fully migrated
|
||||||
|
|
||||||
|
#### 6. MongoDB security hardening (do regardless of Infisical migration)
|
||||||
|
- Ensure `MONGO_URI` uses authentication: `mongodb://user:pass@host:27017/cast-ghl?authSource=admin`
|
||||||
|
- Enable TLS: append `?tls=true&tlsCAFile=/path/to/ca.pem` to the URI
|
||||||
|
- Restrict MongoDB network access to the bridge server IP only (Vultr firewall rules)
|
||||||
|
- Enable MongoDB audit logging for the `cast-ghl` database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Other Future Items
|
||||||
|
|
||||||
|
### Inbound SMS (2-way) — Cast SIM gateway webhook
|
||||||
|
- Tracked in CASA-61
|
||||||
|
- Requires Cast SIM gateway to support outbound webhooks
|
||||||
|
- Bridge handler stub already exists (`PostInboundMessage` in `internal/ghl/api.go`)
|
||||||
|
- Need to agree on Cast webhook payload format and DID → locationId mapping
|
||||||
|
|
||||||
|
### Token refresh proactive scheduling
|
||||||
|
- Currently tokens are refreshed on-demand (when expired at webhook time)
|
||||||
|
- A background goroutine that refreshes tokens 1 hour before expiry would reduce latency on the first webhook after a 24-hour token lifetime
|
||||||
@ -63,7 +63,9 @@ func run() error {
|
|||||||
r.Get("/oauth-callback", oauthHandler.HandleCallback)
|
r.Get("/oauth-callback", oauthHandler.HandleCallback)
|
||||||
r.Post("/api/ghl/v1/webhook/messages", webhookHandler.HandleWebhook)
|
r.Post("/api/ghl/v1/webhook/messages", webhookHandler.HandleWebhook)
|
||||||
r.Post("/api/ghl/v1/webhook/uninstall", webhookHandler.HandleUninstall)
|
r.Post("/api/ghl/v1/webhook/uninstall", webhookHandler.HandleUninstall)
|
||||||
r.Put("/api/admin/locations/{locationId}/sender-id", adminHandler.HandleSetSenderID)
|
r.Get("/api/admin/locations", adminHandler.HandleListLocations)
|
||||||
|
r.Get("/api/admin/locations/{locationId}/config", adminHandler.HandleGetLocationConfig)
|
||||||
|
r.Put("/api/admin/locations/{locationId}/config", adminHandler.HandleSetLocationConfig)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
|
|||||||
@ -29,8 +29,9 @@ func NewClient(baseURL, apiKey, senderID string) *Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendSMS sends an SMS via Cast API. senderID overrides the client-level default when non-empty.
|
// SendSMS sends an SMS via Cast API.
|
||||||
func (c *Client) SendSMS(ctx context.Context, to, message, senderID string) (*SendResponse, error) {
|
// apiKey and senderID override client-level defaults when non-empty.
|
||||||
|
func (c *Client) SendSMS(ctx context.Context, to, message, apiKey, senderID string) (*SendResponse, error) {
|
||||||
req := SendRequest{To: to, Message: message}
|
req := SendRequest{To: to, Message: message}
|
||||||
switch {
|
switch {
|
||||||
case senderID != "":
|
case senderID != "":
|
||||||
@ -47,8 +48,14 @@ func (c *Client) SendSMS(ctx context.Context, to, message, senderID string) (*Se
|
|||||||
const maxRetries = 3
|
const maxRetries = 3
|
||||||
backoff := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second}
|
backoff := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second}
|
||||||
|
|
||||||
|
// Resolve effective API key: per-call override takes precedence over client default.
|
||||||
|
effectiveAPIKey := c.apiKey
|
||||||
|
if apiKey != "" {
|
||||||
|
effectiveAPIKey = apiKey
|
||||||
|
}
|
||||||
|
|
||||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||||
resp, err := c.doRequest(ctx, body)
|
resp, err := c.doRequest(ctx, body, effectiveAPIKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -102,12 +109,12 @@ func (c *Client) SendSMS(ctx context.Context, to, message, senderID string) (*Se
|
|||||||
return nil, &CastAPIError{StatusCode: http.StatusTooManyRequests, APIError: "max retries exceeded"}
|
return nil, &CastAPIError{StatusCode: http.StatusTooManyRequests, APIError: "max retries exceeded"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) doRequest(ctx context.Context, body []byte) (*http.Response, error) {
|
func (c *Client) doRequest(ctx context.Context, body []byte, apiKey string) (*http.Response, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/sms/send", bytes.NewReader(body))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/sms/send", bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("X-API-Key", c.apiKey)
|
req.Header.Set("X-API-Key", apiKey)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
return c.httpClient.Do(req)
|
return c.httpClient.Do(req)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,7 +32,7 @@ func TestSendSMS_Success(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
client := NewClient(srv.URL, "cast_testkey", "")
|
client := NewClient(srv.URL, "cast_testkey", "")
|
||||||
resp, err := client.SendSMS(context.Background(), "09171234567", "test message", "")
|
resp, err := client.SendSMS(context.Background(), "09171234567", "test message", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@ -49,7 +49,7 @@ func TestSendSMS_APIError(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
client := NewClient(srv.URL, "cast_testkey", "")
|
client := NewClient(srv.URL, "cast_testkey", "")
|
||||||
_, err := client.SendSMS(context.Background(), "09171234567", "test", "")
|
_, err := client.SendSMS(context.Background(), "09171234567", "test", "", "")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
@ -70,7 +70,7 @@ func TestSendSMS_SuccessFalseInBody(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
client := NewClient(srv.URL, "cast_testkey", "")
|
client := NewClient(srv.URL, "cast_testkey", "")
|
||||||
_, err := client.SendSMS(context.Background(), "09171234567", "test", "")
|
_, err := client.SendSMS(context.Background(), "09171234567", "test", "", "")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
@ -96,7 +96,7 @@ func TestSendSMS_WithSenderID(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
client := NewClient(srv.URL, "cast_testkey", "CAST")
|
client := NewClient(srv.URL, "cast_testkey", "CAST")
|
||||||
_, err := client.SendSMS(context.Background(), "09171234567", "test", "")
|
_, err := client.SendSMS(context.Background(), "09171234567", "test", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@ -115,7 +115,7 @@ func TestSendSMS_WithoutSenderID(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
client := NewClient(srv.URL, "cast_testkey", "")
|
client := NewClient(srv.URL, "cast_testkey", "")
|
||||||
_, err := client.SendSMS(context.Background(), "09171234567", "test", "")
|
_, err := client.SendSMS(context.Background(), "09171234567", "test", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@ -130,7 +130,7 @@ func TestSendSMS_Unauthorized(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
client := NewClient(srv.URL, "cast_badkey", "")
|
client := NewClient(srv.URL, "cast_badkey", "")
|
||||||
_, err := client.SendSMS(context.Background(), "09171234567", "test", "")
|
_, err := client.SendSMS(context.Background(), "09171234567", "test", "", "")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for 401, got nil")
|
t.Fatal("expected error for 401, got nil")
|
||||||
}
|
}
|
||||||
@ -158,7 +158,7 @@ func TestSendSMS_RetryOn429(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
client := NewClient(srv.URL, "cast_testkey", "")
|
client := NewClient(srv.URL, "cast_testkey", "")
|
||||||
resp, err := client.SendSMS(context.Background(), "09171234567", "test", "")
|
resp, err := client.SendSMS(context.Background(), "09171234567", "test", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,45 +6,134 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.sds.dev/CAST/cast-ghl-plugin/internal/store"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LocationConfigStore is the store interface used by AdminHandler.
|
// AdminStore is the store interface used by AdminHandler.
|
||||||
type LocationConfigStore interface {
|
type AdminStore interface {
|
||||||
UpdateSenderID(ctx context.Context, locationID, senderID string) error
|
GetToken(ctx context.Context, locationID string) (*store.TokenRecord, error)
|
||||||
|
UpdateLocationConfig(ctx context.Context, locationID, senderID, castAPIKey string) error
|
||||||
|
ListTokens(ctx context.Context) ([]*store.TokenRecord, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminHandler exposes internal management endpoints.
|
// AdminHandler exposes management endpoints for the cast-backend admin portal.
|
||||||
// All routes require a valid Authorization: Bearer <adminKey> header.
|
// All routes require Authorization: Bearer <adminKey>.
|
||||||
type AdminHandler struct {
|
type AdminHandler struct {
|
||||||
adminKey string
|
adminKey string
|
||||||
store LocationConfigStore
|
store AdminStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAdminHandler(adminKey string, store LocationConfigStore) *AdminHandler {
|
func NewAdminHandler(adminKey string, store AdminStore) *AdminHandler {
|
||||||
return &AdminHandler{adminKey: adminKey, store: store}
|
return &AdminHandler{adminKey: adminKey, store: store}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSetSenderID sets or clears the per-location Cast sender ID.
|
func (h *AdminHandler) auth(r *http.Request) bool {
|
||||||
|
return h.adminKey != "" && r.Header.Get("Authorization") == "Bearer "+h.adminKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// locationConfigView is the JSON shape returned by GET endpoints.
|
||||||
|
// The Cast API key is masked to avoid leaking secrets over the wire.
|
||||||
|
type locationConfigView struct {
|
||||||
|
LocationID string `json:"location_id"`
|
||||||
|
CompanyID string `json:"company_id"`
|
||||||
|
SenderID string `json:"sender_id"`
|
||||||
|
CastAPIKeyMasked string `json:"cast_api_key"`
|
||||||
|
InstalledAt time.Time `json:"installed_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskAPIKey(key string) string {
|
||||||
|
if key == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(key) <= 12 {
|
||||||
|
return strings.Repeat("*", len(key))
|
||||||
|
}
|
||||||
|
return key[:12] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func toView(r *store.TokenRecord) locationConfigView {
|
||||||
|
return locationConfigView{
|
||||||
|
LocationID: r.LocationID,
|
||||||
|
CompanyID: r.CompanyID,
|
||||||
|
SenderID: r.SenderID,
|
||||||
|
CastAPIKeyMasked: maskAPIKey(r.CastAPIKey),
|
||||||
|
InstalledAt: r.InstalledAt,
|
||||||
|
UpdatedAt: r.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleListLocations returns all installed locations with their config.
|
||||||
//
|
//
|
||||||
// PUT /api/admin/locations/{locationId}/sender-id
|
// GET /api/admin/locations
|
||||||
// Authorization: Bearer <INBOUND_API_KEY>
|
func (h *AdminHandler) HandleListLocations(w http.ResponseWriter, r *http.Request) {
|
||||||
// {"sender_id":"CAST"}
|
if !h.auth(r) {
|
||||||
func (h *AdminHandler) HandleSetSenderID(w http.ResponseWriter, r *http.Request) {
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
if h.adminKey == "" || r.Header.Get("Authorization") != "Bearer "+h.adminKey {
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
records, err := h.store.ListTokens(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("admin: list locations failed", "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
views := make([]locationConfigView, 0, len(records))
|
||||||
|
for _, rec := range records {
|
||||||
|
views = append(views, toView(rec))
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, views)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetLocationConfig returns the config for a single location.
|
||||||
|
//
|
||||||
|
// GET /api/admin/locations/{locationId}/config
|
||||||
|
func (h *AdminHandler) HandleGetLocationConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.auth(r) {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
locationID := chi.URLParam(r, "locationId")
|
locationID := chi.URLParam(r, "locationId")
|
||||||
if locationID == "" {
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
http.Error(w, "missing locationId", http.StatusBadRequest)
|
defer cancel()
|
||||||
|
|
||||||
|
rec, err := h.store.GetToken(ctx, locationID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("admin: get location config failed", "location_id", locationID, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rec == nil {
|
||||||
|
http.Error(w, "location not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1024))
|
writeJSON(w, http.StatusOK, toView(rec))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSetLocationConfig sets the sender ID and Cast API key for a location.
|
||||||
|
//
|
||||||
|
// PUT /api/admin/locations/{locationId}/config
|
||||||
|
// {"sender_id": "CAST", "cast_api_key": "cast_abc123..."}
|
||||||
|
func (h *AdminHandler) HandleSetLocationConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.auth(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
locationID := chi.URLParam(r, "locationId")
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 4096))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@ -52,6 +141,7 @@ func (h *AdminHandler) HandleSetSenderID(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
var payload struct {
|
var payload struct {
|
||||||
SenderID string `json:"sender_id"`
|
SenderID string `json:"sender_id"`
|
||||||
|
CastAPIKey string `json:"cast_api_key"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &payload); err != nil {
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||||
@ -61,8 +151,8 @@ func (h *AdminHandler) HandleSetSenderID(w http.ResponseWriter, r *http.Request)
|
|||||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := h.store.UpdateSenderID(ctx, locationID, payload.SenderID); err != nil {
|
if err := h.store.UpdateLocationConfig(ctx, locationID, payload.SenderID, payload.CastAPIKey); err != nil {
|
||||||
slog.Error("admin: failed to update sender_id", "location_id", locationID, "err", err)
|
slog.Error("admin: update location config failed", "location_id", locationID, "err", err)
|
||||||
if err.Error() == "location not found" {
|
if err.Error() == "location not found" {
|
||||||
http.Error(w, "location not found", http.StatusNotFound)
|
http.Error(w, "location not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
@ -71,8 +161,17 @@ func (h *AdminHandler) HandleSetSenderID(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("admin: sender_id updated", "location_id", locationID, "sender_id", payload.SenderID)
|
slog.Info("admin: location config updated", "location_id", locationID, "sender_id", payload.SenderID, "cast_api_key_set", payload.CastAPIKey != "")
|
||||||
w.Header().Set("Content-Type", "application/json")
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
w.WriteHeader(http.StatusOK)
|
}
|
||||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write(data)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,8 @@ type TokenStore interface {
|
|||||||
SaveToken(ctx context.Context, record *store.TokenRecord) error
|
SaveToken(ctx context.Context, record *store.TokenRecord) error
|
||||||
GetToken(ctx context.Context, locationID string) (*store.TokenRecord, error)
|
GetToken(ctx context.Context, locationID string) (*store.TokenRecord, error)
|
||||||
UpdateToken(ctx context.Context, locationID, accessToken, refreshToken string, expiresAt time.Time) error
|
UpdateToken(ctx context.Context, locationID, accessToken, refreshToken string, expiresAt time.Time) error
|
||||||
UpdateSenderID(ctx context.Context, locationID, senderID string) error
|
UpdateLocationConfig(ctx context.Context, locationID, senderID, castAPIKey string) error
|
||||||
|
ListTokens(ctx context.Context) ([]*store.TokenRecord, error)
|
||||||
DeleteToken(ctx context.Context, locationID string) error
|
DeleteToken(ctx context.Context, locationID string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -201,13 +201,21 @@ func (m *inMemStore) UpdateToken(_ context.Context, locationID, accessToken, ref
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *inMemStore) UpdateSenderID(_ context.Context, locationID, senderID string) error {
|
func (m *inMemStore) UpdateLocationConfig(_ context.Context, locationID, senderID, castAPIKey string) error {
|
||||||
if m.token != nil && m.token.LocationID == locationID {
|
if m.token != nil && m.token.LocationID == locationID {
|
||||||
m.token.SenderID = senderID
|
m.token.SenderID = senderID
|
||||||
|
m.token.CastAPIKey = castAPIKey
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *inMemStore) ListTokens(_ context.Context) ([]*store.TokenRecord, error) {
|
||||||
|
if m.token == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return []*store.TokenRecord{m.token}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *inMemStore) DeleteToken(_ context.Context, locationID string) error {
|
func (m *inMemStore) DeleteToken(_ context.Context, locationID string) error {
|
||||||
if m.token != nil && m.token.LocationID == locationID {
|
if m.token != nil && m.token.LocationID == locationID {
|
||||||
m.token = nil
|
m.token = nil
|
||||||
|
|||||||
@ -138,14 +138,15 @@ func (h *WebhookHandler) processOutbound(webhook OutboundMessageWebhook) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up per-location sender ID; fall back to Cast client default if unset.
|
// Look up per-location Cast config; fall back to client defaults if unset.
|
||||||
var senderID string
|
var senderID, castAPIKey string
|
||||||
if rec, err := h.store.GetToken(ctx, webhook.LocationID); err == nil && rec != nil {
|
if rec, err := h.store.GetToken(ctx, webhook.LocationID); err == nil && rec != nil {
|
||||||
senderID = rec.SenderID
|
senderID = rec.SenderID
|
||||||
|
castAPIKey = rec.CastAPIKey
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("webhook: calling cast api", "message_id", webhook.MessageID, "to", localPhone, "sender_id", senderID)
|
slog.Info("webhook: calling cast api", "message_id", webhook.MessageID, "to", localPhone, "sender_id", senderID, "per_location_key", castAPIKey != "")
|
||||||
_, err = h.castClient.SendSMS(ctx, localPhone, webhook.Message, senderID)
|
_, err = h.castClient.SendSMS(ctx, localPhone, webhook.Message, castAPIKey, senderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("webhook: cast send failed", "message_id", webhook.MessageID, "to", localPhone, "err", err)
|
slog.Error("webhook: cast send failed", "message_id", webhook.MessageID, "to", localPhone, "err", err)
|
||||||
h.updateStatus(ctx, webhook, "failed")
|
h.updateStatus(ctx, webhook, "failed")
|
||||||
|
|||||||
@ -19,6 +19,7 @@ type TokenRecord struct {
|
|||||||
InstalledAt time.Time `bson:"installed_at"`
|
InstalledAt time.Time `bson:"installed_at"`
|
||||||
UpdatedAt time.Time `bson:"updated_at"`
|
UpdatedAt time.Time `bson:"updated_at"`
|
||||||
SenderID string `bson:"sender_id,omitempty"` // per-location Cast sender ID; overrides global default
|
SenderID string `bson:"sender_id,omitempty"` // per-location Cast sender ID; overrides global default
|
||||||
|
CastAPIKey string `bson:"cast_api_key,omitempty"` // per-location Cast API key; overrides global CAST_API_KEY
|
||||||
}
|
}
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
@ -84,10 +85,13 @@ func (s *Store) UpdateToken(ctx context.Context, locationID, accessToken, refres
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateSenderID(ctx context.Context, locationID, senderID string) error {
|
// UpdateLocationConfig sets the per-location Cast sender ID and API key.
|
||||||
|
// Either field may be empty to clear it (falling back to the global default).
|
||||||
|
func (s *Store) UpdateLocationConfig(ctx context.Context, locationID, senderID, castAPIKey string) error {
|
||||||
filter := bson.D{{Key: "location_id", Value: locationID}}
|
filter := bson.D{{Key: "location_id", Value: locationID}}
|
||||||
update := bson.D{{Key: "$set", Value: bson.D{
|
update := bson.D{{Key: "$set", Value: bson.D{
|
||||||
{Key: "sender_id", Value: senderID},
|
{Key: "sender_id", Value: senderID},
|
||||||
|
{Key: "cast_api_key", Value: castAPIKey},
|
||||||
{Key: "updated_at", Value: time.Now()},
|
{Key: "updated_at", Value: time.Now()},
|
||||||
}}}
|
}}}
|
||||||
res, err := s.collection.UpdateOne(ctx, filter, update)
|
res, err := s.collection.UpdateOne(ctx, filter, update)
|
||||||
@ -100,6 +104,20 @@ func (s *Store) UpdateSenderID(ctx context.Context, locationID, senderID string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListTokens returns all installed location records (without OAuth tokens for safety).
|
||||||
|
func (s *Store) ListTokens(ctx context.Context) ([]*TokenRecord, error) {
|
||||||
|
cursor, err := s.collection.Find(ctx, bson.D{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = cursor.Close(ctx) }()
|
||||||
|
var records []*TokenRecord
|
||||||
|
if err := cursor.All(ctx, &records); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteToken(ctx context.Context, locationID string) error {
|
func (s *Store) DeleteToken(ctx context.Context, locationID string) error {
|
||||||
filter := bson.D{{Key: "location_id", Value: locationID}}
|
filter := bson.D{{Key: "location_id", Value: locationID}}
|
||||||
_, err := s.collection.DeleteOne(ctx, filter)
|
_, err := s.collection.DeleteOne(ctx, filter)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user