diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index 9dff56a..76f8166 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -613,6 +613,20 @@ func UpdateUserRegistryDomain(db DBTX, did string, registryDomain string) error return err } +// GetUserDefaultHoldDID returns a user's explicitly-set default hold DID from +// the cached profile, or "" if unset. Unlike GetUserHoldDID it does NOT fall +// back to a manifest's hold_endpoint (which may be a URL, not a DID): callers +// gating on managed-hold membership need the clean default-hold signal where +// "" means the user falls back to the operator's primary managed hold. +func GetUserDefaultHoldDID(db DBTX, did string) string { + var holdDID sql.NullString + _ = db.QueryRow(`SELECT default_hold_did FROM users WHERE did = ?`, did).Scan(&holdDID) + if holdDID.Valid { + return holdDID.String + } + return "" +} + // GetUserHoldDID returns the hold DID for a user. Uses cached default_hold_did // if available, otherwise falls back to the most recent manifest's hold_endpoint. func GetUserHoldDID(db DBTX, did string) string { diff --git a/pkg/appview/handlers/base.go b/pkg/appview/handlers/base.go index 2d6ac92..9bd9a0f 100644 --- a/pkg/appview/handlers/base.go +++ b/pkg/appview/handlers/base.go @@ -3,6 +3,7 @@ package handlers import ( "database/sql" "html/template" + "slices" "atcr.io/pkg/appview/db" "atcr.io/pkg/appview/holdhealth" @@ -53,3 +54,13 @@ type BaseUIHandler struct { BillingEnabled bool // True when the billing build is compiled in and Stripe is configured SourceURL string // Source code URL for the footer "Source" link } + +// IsManagedHold reports whether a hold DID is one of the appview's managed +// holds. An empty holdDID counts as managed: the user has no explicit default +// hold and falls back to the operator's primary managed hold. +func (h *BaseUIHandler) IsManagedHold(holdDID string) bool { + if holdDID == "" { + return true + } + return slices.Contains(h.ManagedHolds, holdDID) +} diff --git a/pkg/appview/handlers/checkout_gate_test.go b/pkg/appview/handlers/checkout_gate_test.go new file mode 100644 index 0000000..f915300 --- /dev/null +++ b/pkg/appview/handlers/checkout_gate_test.go @@ -0,0 +1,80 @@ +//go:build billing + +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "atcr.io/pkg/appview/db" + "atcr.io/pkg/appview/middleware" + "atcr.io/pkg/billing" +) + +func checkoutTestManager(managedHolds []string) *billing.Manager { + cfg := &billing.Config{ + StripeSecretKey: "sk_test_dummy", + WebhookSecret: "whsec_dummy", + Tiers: []billing.BillingTierConfig{{Name: "free", MaxWebhooks: 1}, {Name: "bosun", MaxWebhooks: 5}}, + } + return billing.New(cfg, nil, "did:web:appview", managedHolds, "https://appview", nil) +} + +func TestSubscriptionCheckout_GatedOnManagedHold(t *testing.T) { + conn, err := db.InitDB(":memory:", db.LibsqlConfig{}) + if err != nil { + t.Fatalf("init db: %v", err) + } + defer conn.Close() + + const managed = "did:web:managed-hold" + mgr := checkoutTestManager([]string{managed}) + + mkUser := func(didSuffix, defaultHold string) *db.User { + did := "did:plc:" + didSuffix + if err := db.UpsertUser(conn, &db.User{DID: did, Handle: didSuffix + ".test", PDSEndpoint: "https://pds", LastSeen: time.Now()}); err != nil { + t.Fatalf("upsert user: %v", err) + } + if defaultHold != "" { + if err := db.UpdateUserDefaultHold(conn, did, defaultHold); err != nil { + t.Fatalf("set default hold: %v", err) + } + } + return &db.User{DID: did, Handle: didSuffix + ".test"} + } + + h := &SubscriptionCheckoutHandler{BaseUIHandler: BaseUIHandler{ + BillingManager: mgr, + ManagedHolds: []string{managed}, + DB: conn, + }} + + // Self-hosted default hold → checkout rejected with 403 before any Stripe call. + t.Run("self-hosted -> 403", func(t *testing.T) { + user := mkUser("selfhosted", "did:web:someones-own-hold") + req := middleware.WithUser(httptest.NewRequest(http.MethodGet, "/settings/subscription/checkout?tier=bosun", nil), user) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403", rr.Code) + } + }) + + // Managed default hold → passes the hold gate. With no tier param it falls + // through to the 400 tier-required check (so we never reach Stripe), proving + // the gate doesn't block managed users. + t.Run("managed -> passes gate (400 on missing tier)", func(t *testing.T) { + user := mkUser("managed", managed) + req := middleware.WithUser(httptest.NewRequest(http.MethodGet, "/settings/subscription/checkout", nil), user) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code == http.StatusForbidden { + t.Errorf("managed user wrongly blocked with 403") + } + if rr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (missing tier, i.e. passed the hold gate)", rr.Code) + } + }) +} diff --git a/pkg/appview/handlers/image_advisor.go b/pkg/appview/handlers/image_advisor.go index 2462db3..4bd6dfc 100644 --- a/pkg/appview/handlers/image_advisor.go +++ b/pkg/appview/handlers/image_advisor.go @@ -121,9 +121,15 @@ func (h *ImageAdvisorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) return } - // Check billing access + // Check billing access. Paid features require a managed default hold, so a + // paid user on a self-hosted hold also fails here — show them the right + // message (switch holds) rather than "upgrade". if h.BillingManager != nil && !h.BillingManager.HasAIAdvisor(user.DID) { - h.renderResults(w, imageAdvisorData{Error: "upgrade_required"}) + errCode := "upgrade_required" + if !h.IsManagedHold(db.GetUserDefaultHoldDID(h.DB, user.DID)) { + errCode = "managed_hold_required" + } + h.renderResults(w, imageAdvisorData{Error: errCode}) return } diff --git a/pkg/appview/handlers/managed_hold_test.go b/pkg/appview/handlers/managed_hold_test.go new file mode 100644 index 0000000..eed76ba --- /dev/null +++ b/pkg/appview/handlers/managed_hold_test.go @@ -0,0 +1,37 @@ +package handlers + +import "testing" + +func TestIsManagedHold(t *testing.T) { + h := &BaseUIHandler{ManagedHolds: []string{"did:web:hold01", "did:web:hold02"}} + + cases := []struct { + name string + holdDID string + want bool + }{ + {"empty defaults to managed", "", true}, + {"managed member", "did:web:hold01", true}, + {"other managed member", "did:web:hold02", true}, + {"self-hosted", "did:web:someones-own-hold", false}, + {"unknown", "did:plc:abc123", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := h.IsManagedHold(tc.holdDID); got != tc.want { + t.Errorf("IsManagedHold(%q) = %v, want %v", tc.holdDID, got, tc.want) + } + }) + } +} + +// With no managed holds configured, only the empty (fallback) hold is managed. +func TestIsManagedHold_NoManagedHolds(t *testing.T) { + h := &BaseUIHandler{} + if !h.IsManagedHold("") { + t.Error("empty hold should count as managed (operator fallback)") + } + if h.IsManagedHold("did:web:anything") { + t.Error("no holds configured: a concrete hold should not be managed") + } +} diff --git a/pkg/appview/handlers/settings.go b/pkg/appview/handlers/settings.go index d53705a..6ebd92b 100644 --- a/pkg/appview/handlers/settings.go +++ b/pkg/appview/handlers/settings.go @@ -101,6 +101,12 @@ type settingsPageData struct { EligibleHolds []HoldDisplay WebhooksData webhooksTemplateData Subscription SubscriptionDisplay + // SelfHostedHold is true when the user's active hold is self-hosted while the + // deployment has billing enabled — paid features (and billing) don't apply. + SelfHostedHold bool + // SelfHostedActivePlan is true when SelfHostedHold AND the user still has an + // active Stripe subscription, so they're being charged without paid features. + SelfHostedActivePlan bool } // ServeHTTP redirects /settings to /settings/user. @@ -143,11 +149,24 @@ func (h *SettingsHandler) ServeTab(tab string) http.HandlerFunc { ).WithRobots("noindex"). WithSiteName(h.ClientShortName) + // Billing is a managed-hold concept: hide it for users whose active hold + // is self-hosted (paid features require a managed hold). An unset default + // hold means the operator's primary managed hold, so it counts as managed. + // + // Read the resolved default_hold_did rather than profile.DefaultHold. The + // profile field is the raw record value and may be a URL-form reference; + // jetstream resolves it to a DID on the way into the DB, and the + // server-side entitlement gate reads that resolved value. Comparing the + // raw form here against managed DIDs would tell a managed user they are + // self-hosted while their entitlements say otherwise. + activeHoldDID := db.GetUserDefaultHoldDID(h.DB, user.DID) + showBilling := h.BillingEnabled && h.IsManagedHold(activeHoldDID) + data := settingsPageData{ PageData: NewPageData(r, &h.BaseUIHandler), Meta: meta, ActiveTab: tab, - Tabs: settingsTabs(h.BillingEnabled), + Tabs: settingsTabs(showBilling), Profile: settingsProfile{ Handle: user.Handle, DID: user.DID, @@ -168,8 +187,16 @@ func (h *SettingsHandler) ServeTab(tab string) http.HandlerFunc { switch tab { case "storage": data.ActiveHold, data.OtherHolds, data.MemberHolds, data.EligibleHolds = h.buildHoldsData(r.Context(), user.DID, profile.DefaultHold) + // On a self-hosted hold, billing/paid features don't apply. If the + // user still has an active plan, surface a cancel/manage banner. + if h.BillingEnabled && !h.IsManagedHold(activeHoldDID) { + data.SelfHostedHold = true + if h.BillingManager != nil { + data.SelfHostedActivePlan = h.BillingManager.HasActiveSubscription(user.DID) + } + } case "billing": - data.Subscription = h.buildSubscriptionDisplay(user.DID) + data.Subscription = h.buildSubscriptionDisplay(user.DID, activeHoldDID) case "webhooks": data.WebhooksData = h.buildWebhooksData(user.DID) } @@ -294,10 +321,15 @@ func (h *SettingsHandler) buildWebhooksData(userDID string) webhooksTemplateData } // buildSubscriptionDisplay fetches subscription info for SSR in the settings page. -func (h *SettingsHandler) buildSubscriptionDisplay(userDID string) SubscriptionDisplay { +// Billing is hidden for users whose active (default) hold is self-hosted: paid +// features require a managed hold. +func (h *SettingsHandler) buildSubscriptionDisplay(userDID, defaultHold string) SubscriptionDisplay { if h.BillingManager == nil || !h.BillingManager.Enabled() { return SubscriptionDisplay{HideBilling: true} } + if !h.IsManagedHold(defaultHold) { + return SubscriptionDisplay{HideBilling: true} + } info, err := h.BillingManager.GetSubscriptionInfo(userDID) if err != nil { diff --git a/pkg/appview/handlers/subscription.go b/pkg/appview/handlers/subscription.go index 8443317..4a39619 100644 --- a/pkg/appview/handlers/subscription.go +++ b/pkg/appview/handlers/subscription.go @@ -4,6 +4,7 @@ import ( "log/slog" "net/http" + "atcr.io/pkg/appview/db" "atcr.io/pkg/appview/middleware" "atcr.io/pkg/billing" ) @@ -49,6 +50,16 @@ func (h *SubscriptionCheckoutHandler) ServeHTTP(w http.ResponseWriter, r *http.R return } + // Paid features (and therefore checkout) require a managed default hold. + // The UI hides the button, but the route is directly reachable — gate it so + // a self-hosted user can't pay for features they won't receive. Read the + // primary DB so a just-switched hold is reflected immediately. The portal + // route stays open so a subscriber on a self-hosted hold can still cancel. + if !h.IsManagedHold(db.GetUserDefaultHoldDID(h.DB, user.DID)) { + http.Error(w, "Billing is only available on managed holds", http.StatusForbidden) + return + } + tier := r.URL.Query().Get("tier") if tier == "" { http.Error(w, "tier parameter required", http.StatusBadRequest) diff --git a/pkg/appview/jetstream/processor.go b/pkg/appview/jetstream/processor.go index 877ff27..3eff352 100644 --- a/pkg/appview/jetstream/processor.go +++ b/pkg/appview/jetstream/processor.go @@ -552,11 +552,17 @@ func (p *Processor) ProcessSailorProfile(ctx context.Context, did string, record return nil } - // Convert hold URL/DID to canonical DID + // Convert hold URL/DID to canonical DID. On failure, cache the raw reference + // rather than leaving default_hold_did empty: "" means "operator default" + // (managed) to the billing gate, so a self-hosted user whose hold is briefly + // unresolvable must not fall through to managed. A managed hold's DID never + // fails resolution (DIDs return as-is), so only unreachable URL-form refs land + // here, and a raw non-DID value correctly reads as non-managed (fail closed). holdDID, err := atproto.ResolveHoldDID(ctx, profileRecord.DefaultHold) if err != nil { - slog.Warn("Invalid hold reference in profile", "component", "processor", "did", did, "default_hold", profileRecord.DefaultHold, "error", err) - return nil + slog.Warn("Invalid hold reference in profile; caching raw value (fails closed for billing)", + "component", "processor", "did", did, "default_hold", profileRecord.DefaultHold, "error", err) + holdDID = profileRecord.DefaultHold } // Cache default hold DID on the user record diff --git a/pkg/appview/jetstream/profile_failclosed_test.go b/pkg/appview/jetstream/profile_failclosed_test.go new file mode 100644 index 0000000..dea7e29 --- /dev/null +++ b/pkg/appview/jetstream/profile_failclosed_test.go @@ -0,0 +1,62 @@ +package jetstream + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "atcr.io/pkg/appview/db" + "atcr.io/pkg/atproto" +) + +// When a profile's default hold can't be resolved, the processor must cache the +// raw reference (non-empty) rather than leaving default_hold_did = "". The +// billing gate treats "" as the operator's managed default, so an empty value +// for a genuinely self-hosted (but unreachable) hold would fail OPEN. A raw, +// non-managed value reads as non-managed → fails closed. +func TestProcessSailorProfile_UnresolvableHold_CachesRawValue(t *testing.T) { + database, err := db.InitDB(":memory:", db.LibsqlConfig{}) + if err != nil { + t.Fatalf("init db: %v", err) + } + defer database.Close() + + const did = "did:plc:failclosed" + if err := db.UpsertUser(database, &db.User{ + DID: did, Handle: "fc.test", PDSEndpoint: "https://pds", LastSeen: time.Now(), + }); err != nil { + t.Fatalf("upsert user: %v", err) + } + + // A hold whose .well-known/atproto-did returns 404 → ResolveHoldDID fails + // deterministically without depending on external DNS/network. + holdSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer holdSrv.Close() + + record, err := json.Marshal(atproto.SailorProfileRecord{ + Type: "io.atcr.sailor.profile", + DefaultHold: holdSrv.URL, // URL-form, unresolvable → resolution fails + CreatedAt: time.Now().UTC(), + }) + if err != nil { + t.Fatalf("marshal profile: %v", err) + } + + p := NewProcessor(database, false, nil) + if err := p.ProcessSailorProfile(context.Background(), did, record, nil); err != nil { + t.Fatalf("ProcessSailorProfile: %v", err) + } + + got := db.GetUserDefaultHoldDID(database, did) + if got == "" { + t.Fatal("default_hold_did is empty — would fail OPEN for a self-hosted user") + } + if got != holdSrv.URL { + t.Errorf("default_hold_did = %q, want raw value %q", got, holdSrv.URL) + } +} diff --git a/pkg/appview/server.go b/pkg/appview/server.go index 3a8bb51..ca6e256 100644 --- a/pkg/appview/server.go +++ b/pkg/appview/server.go @@ -12,6 +12,7 @@ import ( "net/url" "os" "os/signal" + "slices" "strings" "syscall" "time" @@ -276,6 +277,17 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, isCaptain, _ := db.IsHoldCaptain(roDB, userDID, managedHolds) return isCaptain }) + // Paid features require the user's active (default) hold to be a managed + // hold. An unset default hold means they use the operator's primary + // managed hold, so it counts as managed. Read the primary DB (not roDB): + // a hold switch writes default_hold_did to the primary, and the replica + // may lag, so reading roDB could keep paid features alive briefly after a + // switch to self-hosted. + primaryDB := s.Database + s.BillingManager.SetActiveHoldChecker(func(userDID string) bool { + holdDID := db.GetUserDefaultHoldDID(primaryDB, userDID) + return holdDID == "" || slices.Contains(managedHolds, holdDID) + }) } if s.BillingManager.Enabled() { // Fail closed: an empty Stripe webhook secret makes webhooks forgeable diff --git a/pkg/appview/templates/partials/image-advisor-results.html b/pkg/appview/templates/partials/image-advisor-results.html index cc7f500..7fde8c8 100644 --- a/pkg/appview/templates/partials/image-advisor-results.html +++ b/pkg/appview/templates/partials/image-advisor-results.html @@ -4,6 +4,11 @@ {{ icon "sparkles" "size-4" }} AI Image Advisor is a paid feature. Upgrade your plan to unlock image analysis. +{{ else if eq .Error "managed_hold_required" }} +
You're on a self-hosted hold but still have an active plan
+Paid features apply only on managed holds, so your plan is currently inactive. You can manage or cancel it anytime.
+