feat(mana-sync): per-app billing exemption — Cards bypasses sync gate

mana-sync's billing middleware short-circuited every push/pull with
402 for users without a sync subscription. Cards promises free Sync
in its Phase-1 GUIDELINES, so it shouldn't gate its own users on a
mana-credits subscription it never sells.

Implementation:
  • billing.NewChecker now takes an exemptApps slice. The middleware
    extracts {appId} from the URL path and short-circuits before the
    user lookup if the app is in the set.
  • Configurable via the BILLING_EXEMPT_APPS env var (comma-separated).
  • Set BILLING_EXEMPT_APPS=cards on the mana-sync container so the
    cards.mana.how Sync loop stops 402-ing.
  • Tests cover the exemption + the empty/whitespace edge cases. All
    other apps keep the original behaviour (fail-open if mana-credits
    is unreachable, 402 if it explicitly says inactive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-07 15:01:54 +02:00
parent 6f1b0329f0
commit ceed8ccd64
5 changed files with 145 additions and 16 deletions

View file

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

View file

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

View file

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

View file

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