Head of Product & Engineering 9995027093
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat: per-location Cast API key and unified admin config API
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>
2026-04-06 14:08:29 +02:00

130 lines
3.9 KiB
Go

package store
import (
"context"
"errors"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
type TokenRecord struct {
LocationID string `bson:"location_id"`
CompanyID string `bson:"company_id"`
AccessToken string `bson:"access_token"`
RefreshToken string `bson:"refresh_token"`
ExpiresAt time.Time `bson:"expires_at"`
InstalledAt time.Time `bson:"installed_at"`
UpdatedAt time.Time `bson:"updated_at"`
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 {
client *mongo.Client
collection *mongo.Collection
}
func NewStore(ctx context.Context, uri string) (*Store, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
client, err := mongo.Connect(options.Client().ApplyURI(uri))
if err != nil {
return nil, err
}
if err := client.Ping(ctx, nil); err != nil {
return nil, err
}
col := client.Database("cast-ghl").Collection("oauth_tokens")
indexModel := mongo.IndexModel{
Keys: bson.D{{Key: "location_id", Value: 1}},
Options: options.Index().SetUnique(true),
}
if _, err := col.Indexes().CreateOne(ctx, indexModel); err != nil {
return nil, err
}
return &Store{client: client, collection: col}, nil
}
func (s *Store) SaveToken(ctx context.Context, record *TokenRecord) error {
record.UpdatedAt = time.Now()
filter := bson.D{{Key: "location_id", Value: record.LocationID}}
opts := options.Replace().SetUpsert(true)
_, err := s.collection.ReplaceOne(ctx, filter, record, opts)
return err
}
func (s *Store) GetToken(ctx context.Context, locationID string) (*TokenRecord, error) {
filter := bson.D{{Key: "location_id", Value: locationID}}
var record TokenRecord
err := s.collection.FindOne(ctx, filter).Decode(&record)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
if err != nil {
return nil, err
}
return &record, nil
}
func (s *Store) UpdateToken(ctx context.Context, locationID, accessToken, refreshToken string, expiresAt time.Time) error {
filter := bson.D{{Key: "location_id", Value: locationID}}
update := bson.D{{Key: "$set", Value: bson.D{
{Key: "access_token", Value: accessToken},
{Key: "refresh_token", Value: refreshToken},
{Key: "expires_at", Value: expiresAt},
{Key: "updated_at", Value: time.Now()},
}}}
_, err := s.collection.UpdateOne(ctx, filter, update)
return err
}
// 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}}
update := bson.D{{Key: "$set", Value: bson.D{
{Key: "sender_id", Value: senderID},
{Key: "cast_api_key", Value: castAPIKey},
{Key: "updated_at", Value: time.Now()},
}}}
res, err := s.collection.UpdateOne(ctx, filter, update)
if err != nil {
return err
}
if res.MatchedCount == 0 {
return errors.New("location not found")
}
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 {
filter := bson.D{{Key: "location_id", Value: locationID}}
_, err := s.collection.DeleteOne(ctx, filter)
return err
}
func (s *Store) Close(ctx context.Context) error {
return s.client.Disconnect(ctx)
}