mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 17:24:16 +00:00
Entitlements were keyed on the Stripe subscription alone, so a subscriber
who switched to a self-hosted hold kept paying for features the appview
cannot deliver, and could still reach checkout.
- billing.ActiveHoldChecker and Manager.onManagedHold gate every
entitlement. An empty default hold counts as managed: the user has no
explicit preference and falls back to the operator's primary managed
hold.
- The checker reads the primary DB, not the read replica. A hold switch
writes default_hold_did to the primary, and replica lag would keep
paid features alive after a switch away.
- db.GetUserDefaultHoldDID is the clean default-hold signal, unlike
GetUserHoldDID which falls back to a manifest hold_endpoint (a URL,
not a DID).
- Jetstream fails closed: an unresolvable hold reference is cached raw
rather than left empty, since an empty value reads as managed.
- UI: the billing tab is hidden on self-hosted, a cancel/manage banner
appears when a self-hosted user still has an active plan, the image
advisor returns managed_hold_required instead of upgrade_required,
and the checkout route returns 403. The portal stays open so existing
subscribers can still cancel.
Two consistency fixes fall out of wiring this up:
The settings UI reads the resolved default_hold_did rather than the raw
profile.DefaultHold. The profile field is the record value as written and
may be a URL-form reference; jetstream resolves it to a DID on the way
into the DB, and the server-side gate reads that resolved value. Comparing
the raw form against managed DIDs would show the "you are self-hosted"
banner and hide billing from a user whose entitlements say otherwise.
HasAIAdvisor falls back to the free tier's AIAdvisor setting when
off-managed instead of a hard false, matching GetWebhookLimits. Losing a
managed hold should drop a user to free-tier entitlements, not below them.
BEHAVIOR CHANGE for existing paying users on self-hosted holds: they lose
the AI advisor, supporter badge and paid webhook limits as soon as this
deploys, while Stripe keeps charging them. The only notice is the banner
on /settings/storage, which they have to visit to see. Decide on a
migration (notification, or a one-time reconciliation over active
subscriptions) before shipping this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
38 lines
1.0 KiB
Go
38 lines
1.0 KiB
Go
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")
|
|
}
|
|
}
|