mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +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>
63 lines
1.9 KiB
Go
63 lines
1.9 KiB
Go
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)
|
|
}
|
|
}
|