mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 12:17:00 +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>
67 lines
2.3 KiB
Go
67 lines
2.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"database/sql"
|
|
"html/template"
|
|
"slices"
|
|
|
|
"atcr.io/pkg/appview/db"
|
|
"atcr.io/pkg/appview/holdhealth"
|
|
"atcr.io/pkg/appview/readme"
|
|
"atcr.io/pkg/appview/webhooks"
|
|
"atcr.io/pkg/auth/oauth"
|
|
"atcr.io/pkg/billing"
|
|
indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
|
|
"github.com/bluesky-social/indigo/atproto/identity"
|
|
)
|
|
|
|
// BaseUIHandler contains all dependencies for UI handlers.
|
|
// Handlers embed this and use whatever fields they need.
|
|
// Route registration becomes simply &Handler{base} for everything.
|
|
type BaseUIHandler struct {
|
|
// Display
|
|
Templates *template.Template
|
|
RegistryURL string // Primary Docker registry domain (e.g., "buoy.cr" or "atcr.io")
|
|
RegistryDomains []string // All configured registry domains; users may pick one as their default
|
|
SiteURL string // Website domain (e.g., "seamark.dev" or "atcr.io")
|
|
|
|
// Database (handlers choose which to use)
|
|
DB *sql.DB // Write access
|
|
ReadOnlyDB *sql.DB // Read-only access
|
|
|
|
// Services
|
|
Refresher *oauth.Refresher
|
|
HealthChecker *holdhealth.Checker
|
|
ReadmeFetcher *readme.Fetcher
|
|
Directory identity.Directory
|
|
BillingManager *billing.Manager
|
|
WebhookDispatcher *webhooks.Dispatcher
|
|
OAuthClientApp *indigooauth.ClientApp
|
|
|
|
// Stores
|
|
SessionStore *db.SessionStore
|
|
DeviceStore *db.DeviceStore
|
|
OAuthStore *db.OAuthStore
|
|
|
|
// Config
|
|
DefaultHoldDID string
|
|
ManagedHolds []string // DIDs of holds this appview operates (server.managed_holds)
|
|
CompanyName string
|
|
Jurisdiction string
|
|
ClientName string // Full name: "AT Container Registry"
|
|
ClientShortName string // Short name: "ATCR"
|
|
AIAdvisorEnabled bool // True when billing is fully configured AND Claude API key is set
|
|
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)
|
|
}
|