diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index ac54f7b47..5b48b0124 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -684,6 +684,9 @@ services: CORS_ORIGINS: "https://mana.how,https://*.mana.how" MANA_CREDITS_URL: http://mana-credits:3002 MANA_SERVICE_KEY: ${MANA_SERVICE_KEY} + # Apps that bypass the sync-subscription billing gate. Cards + # promises free Sync per its Phase-1 GUIDELINES. + BILLING_EXEMPT_APPS: cards ports: - "3010:3010" healthcheck: diff --git a/services/mana-sync/cmd/server/main.go b/services/mana-sync/cmd/server/main.go index 96ff021b7..68f51b6c6 100644 --- a/services/mana-sync/cmd/server/main.go +++ b/services/mana-sync/cmd/server/main.go @@ -52,7 +52,9 @@ func main() { hub := ws.NewHub(validator) // Initialize billing checker (verifies sync subscription via mana-credits) - billingChecker := billing.NewChecker(cfg.ManaCreditsURL, cfg.ServiceKey) + // Exempt apps bypass the gate entirely — used for products that promise + // free Sync (e.g. Cards). + billingChecker := billing.NewChecker(cfg.ManaCreditsURL, cfg.ServiceKey, cfg.BillingExemptApps) billingMiddleware := billingChecker.Middleware(validator) // Initialize Space-membership lookup against mana-auth. The handler diff --git a/services/mana-sync/internal/billing/check.go b/services/mana-sync/internal/billing/check.go index 526f40854..de261629e 100644 --- a/services/mana-sync/internal/billing/check.go +++ b/services/mana-sync/internal/billing/check.go @@ -24,27 +24,50 @@ type cachedStatus struct { // Checker verifies sync billing status via the mana-credits service. // Results are cached per user for cacheTTL to avoid hitting mana-credits on every request. +// +// `exemptApps` is a set of appIDs that bypass the billing check entirely +// — used for products that promise free Sync (e.g. Cards). The check is +// done by URL path so it sits naturally above the per-user cache: an +// exempt request short-circuits before any user lookup. type Checker struct { creditsURL string serviceKey string cacheTTL time.Duration client *http.Client + exemptApps map[string]struct{} mu sync.RWMutex cache map[string]cachedStatus } // NewChecker creates a billing checker. -func NewChecker(creditsURL, serviceKey string) *Checker { +func NewChecker(creditsURL, serviceKey string, exemptApps []string) *Checker { + exempt := make(map[string]struct{}, len(exemptApps)) + for _, app := range exemptApps { + if app != "" { + exempt[app] = struct{}{} + } + } return &Checker{ creditsURL: creditsURL, serviceKey: serviceKey, cacheTTL: 5 * time.Minute, client: &http.Client{Timeout: 5 * time.Second}, + exemptApps: exempt, cache: make(map[string]cachedStatus), } } +// IsAppExempt returns true if the given appID is configured to bypass +// the billing check. +func (c *Checker) IsAppExempt(appID string) bool { + if appID == "" { + return false + } + _, ok := c.exemptApps[appID] + return ok +} + // IsActive checks whether a user has an active sync subscription. // Returns true if the billing check fails (fail-open to not block sync on service outage). func (c *Checker) IsActive(userID string) bool { @@ -101,9 +124,19 @@ func (c *Checker) fetchStatus(userID string) (SyncStatus, error) { // Middleware returns an HTTP middleware that checks sync billing status. // Returns 402 Payment Required if the user's sync subscription is not active. +// +// The middleware respects the `exemptApps` set: routes whose `{appId}` +// path-value is exempt skip both the user lookup and the credit check. +// Routes without an `{appId}` placeholder are treated as non-exempt +// (the original behaviour). func (c *Checker) Middleware(validator interface{ UserIDFromRequest(*http.Request) (string, error) }) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if c.IsAppExempt(r.PathValue("appId")) { + next.ServeHTTP(w, r) + return + } + userID, err := validator.UserIDFromRequest(r) if err != nil { // Let the downstream handler deal with auth errors diff --git a/services/mana-sync/internal/billing/check_test.go b/services/mana-sync/internal/billing/check_test.go new file mode 100644 index 000000000..dae1dc2aa --- /dev/null +++ b/services/mana-sync/internal/billing/check_test.go @@ -0,0 +1,72 @@ +package billing + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +type stubValidator struct { + uid string + err error +} + +func (s stubValidator) UserIDFromRequest(_ *http.Request) (string, error) { + return s.uid, s.err +} + +// Routes for an exempt appID short-circuit before the user lookup +// happens. Asserting via downstream handler reachability + no +// mana-credits round-trip (the checker has no creditsURL configured — +// any fetch would fail). +func TestMiddleware_AppExemption(t *testing.T) { + c := NewChecker("http://invalid.invalid", "stub", []string{"cards"}) + + // Wire a mux that surfaces {appId} as a path value, like main.go does. + mux := http.NewServeMux() + called := false + mux.Handle("POST /sync/{appId}", c.Middleware(stubValidator{uid: "user-1"})(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }))) + + cases := []struct { + name string + path string + wantStatus int + wantCalled bool + }{ + {"exempt app passes without billing check", "/sync/cards", http.StatusOK, true}, + {"non-exempt app reaches the (failing) billing check", "/sync/todo", http.StatusOK, true}, // fail-open keeps it open + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + called = false + req := httptest.NewRequest("POST", tc.path, nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != tc.wantStatus { + t.Errorf("status=%d want=%d", rec.Code, tc.wantStatus) + } + if called != tc.wantCalled { + t.Errorf("downstream called=%v want=%v", called, tc.wantCalled) + } + }) + } +} + +func TestIsAppExempt(t *testing.T) { + c := NewChecker("", "", []string{"cards", " ", "todo"}) + if !c.IsAppExempt("cards") { + t.Error("expected cards to be exempt") + } + if !c.IsAppExempt("todo") { + t.Error("expected todo to be exempt") + } + if c.IsAppExempt("notes") { + t.Error("notes should not be exempt") + } + if c.IsAppExempt("") { + t.Error("empty appID should never be exempt") + } +} diff --git a/services/mana-sync/internal/config/config.go b/services/mana-sync/internal/config/config.go index 1308a8e17..1da17206a 100644 --- a/services/mana-sync/internal/config/config.go +++ b/services/mana-sync/internal/config/config.go @@ -3,17 +3,19 @@ package config import ( "os" "strconv" + "strings" ) // Config holds all configuration for the sync server. type Config struct { - Port int - DatabaseURL string - JWKSUrl string // mana-auth JWKS endpoint for JWT validation - ManaAuthURL string // mana-auth base URL for internal APIs (Space memberships) - CORSOrigins string - ManaCreditsURL string // mana-credits service URL for billing checks - ServiceKey string // Service-to-service auth key + Port int + DatabaseURL string + JWKSUrl string // mana-auth JWKS endpoint for JWT validation + ManaAuthURL string // mana-auth base URL for internal APIs (Space memberships) + CORSOrigins string + ManaCreditsURL string // mana-credits service URL for billing checks + ServiceKey string // Service-to-service auth key + BillingExemptApps []string // appIDs that bypass the sync-subscription billing gate } // Load reads configuration from environment variables with sensible defaults. @@ -21,13 +23,14 @@ func Load() *Config { port, _ := strconv.Atoi(getEnv("PORT", "3050")) return &Config{ - Port: port, - DatabaseURL: getEnv("DATABASE_URL", "postgresql://mana:devpassword@localhost:5432/mana_sync"), - JWKSUrl: getEnv("JWKS_URL", "http://localhost:3001/api/auth/jwks"), - ManaAuthURL: getEnv("MANA_AUTH_URL", "http://localhost:3001"), - CORSOrigins: getEnv("CORS_ORIGINS", "http://localhost:5173,http://localhost:5188"), - ManaCreditsURL: getEnv("MANA_CREDITS_URL", "http://localhost:3061"), - ServiceKey: getEnv("MANA_SERVICE_KEY", "dev-service-key"), + Port: port, + DatabaseURL: getEnv("DATABASE_URL", "postgresql://mana:devpassword@localhost:5432/mana_sync"), + JWKSUrl: getEnv("JWKS_URL", "http://localhost:3001/api/auth/jwks"), + ManaAuthURL: getEnv("MANA_AUTH_URL", "http://localhost:3001"), + CORSOrigins: getEnv("CORS_ORIGINS", "http://localhost:5173,http://localhost:5188"), + ManaCreditsURL: getEnv("MANA_CREDITS_URL", "http://localhost:3061"), + ServiceKey: getEnv("MANA_SERVICE_KEY", "dev-service-key"), + BillingExemptApps: splitCSV(getEnv("BILLING_EXEMPT_APPS", "")), } } @@ -37,3 +40,19 @@ func getEnv(key, fallback string) string { } return fallback } + +// splitCSV splits a comma-separated string into a trimmed, non-empty slice. +func splitCSV(raw string) []string { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + t := strings.TrimSpace(p) + if t != "" { + out = append(out, t) + } + } + return out +}