mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
6f1b0329f0
commit
ceed8ccd64
5 changed files with 145 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
72
services/mana-sync/internal/billing/check_test.go
Normal file
72
services/mana-sync/internal/billing/check_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue