feat: per-location Cast API key and unified admin config API
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:
Head of Product & Engineering 2026-04-06 14:08:20 +02:00
parent 5312eb0ca2
commit 9995027093
9 changed files with 255 additions and 44 deletions

75
FUTURE_DEV.md Normal file
View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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