Files
Evan JarrettandClaude Opus 5 2b71be59f7 billing: require a managed hold for paid features
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>
2026-08-09 21:14:58 -05:00

789 lines
25 KiB
Go

package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"slices"
"strings"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/appview/storage"
"atcr.io/pkg/appview/webhooks"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// HoldDisplay represents a hold for display in the UI
type HoldDisplay struct {
DID string `json:"did"`
DisplayName string `json:"displayName"`
Region string `json:"region"`
Membership string `json:"membership"`
Permissions []string `json:"permissions,omitempty"`
ReadOnly bool `json:"readOnly"` // crew member without blob:write — pushes would be rejected
Status string `json:"status"` // "" = unknown, "online", "offline"
IsActive bool `json:"isActive"`
}
// SettingsHandler handles the settings page — dispatches per-tab.
type SettingsHandler struct {
BaseUIHandler
}
// settingsTab describes a tab entry rendered in the tablist.
// Icons live in the template (settings.html) so the icon-sprite generator
// — which only scans templates and JS — can discover them.
type settingsTab struct {
Slug string
Label string
}
func settingsTabs(billingEnabled bool) []settingsTab {
tabs := []settingsTab{{Slug: "user", Label: "User"}}
if billingEnabled {
tabs = append(tabs, settingsTab{Slug: "billing", Label: "Billing"})
}
tabs = append(tabs,
settingsTab{Slug: "storage", Label: "Storage"},
settingsTab{Slug: "devices", Label: "Devices"},
settingsTab{Slug: "webhooks", Label: "Webhooks"},
settingsTab{Slug: "advanced", Label: "Advanced"},
)
return tabs
}
func isValidSettingsTab(tab string, billingEnabled bool) bool {
switch tab {
case "user", "storage", "devices", "webhooks", "advanced":
return true
case "billing":
return billingEnabled
}
return false
}
// settingsProfile is the sidebar identity info shared across all tabs.
type settingsProfile struct {
Handle string
DID string
PDSEndpoint string
DefaultHold string
AutoRemoveUntagged bool
OciClient string
RegistryDomain string
AIAdvisorEnabled bool
HasAIAdvisorAccess bool
}
// settingsPageData is the struct passed to the settings shell + panel templates.
// MemberHolds are holds where the user is already owner/crew; EligibleHolds
// are ones they can opt-in to join. Splitting them upstream keeps the
// hold_selector template from doing filter-the-same-list-twice gymnastics.
type settingsPageData struct {
PageData
Meta *PageMeta
ActiveTab string
Tabs []settingsTab
Profile settingsProfile
RegistryDomains []string
ActiveHold *HoldDisplay
OtherHolds []HoldDisplay
MemberHolds []HoldDisplay
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.
func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/settings/user", http.StatusFound)
}
// ServeTab returns an http.Handler for a specific settings tab.
// If HX-Request is set, only the panel fragment is rendered.
func (h *SettingsHandler) ServeTab(tab string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !isValidSettingsTab(tab, h.BillingEnabled) {
http.NotFound(w, r)
return
}
user := middleware.GetUser(r)
if user == nil {
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings/"+tab, http.StatusFound)
return
}
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
profile, err := storage.GetProfile(r.Context(), client)
if err != nil {
slog.Warn("Failed to fetch profile, logging out", "component", "settings", "did", user.DID, "error", err)
http.Redirect(w, r, "/auth/logout", http.StatusFound)
return
}
if profile == nil {
slog.Warn("Profile doesn't exist, logging out", "component", "settings", "did", user.DID)
http.Redirect(w, r, "/auth/logout", http.StatusFound)
return
}
meta := NewPageMeta(
"Settings - "+h.ClientShortName,
"Manage your "+h.ClientShortName+" account settings, authorized devices, and storage preferences",
).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(showBilling),
Profile: settingsProfile{
Handle: user.Handle,
DID: user.DID,
PDSEndpoint: user.PDSEndpoint,
DefaultHold: profile.DefaultHold,
AutoRemoveUntagged: profile.AutoRemoveUntagged,
OciClient: profile.OciClient,
RegistryDomain: profile.RegistryDomain,
AIAdvisorEnabled: profile.AIAdvisorEnabled == nil || *profile.AIAdvisorEnabled,
},
RegistryDomains: h.RegistryDomains,
}
if h.BillingManager != nil {
data.Profile.HasAIAdvisorAccess = h.BillingManager.HasAIAdvisor(user.DID)
}
// Per-tab data fetch.
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, activeHoldDID)
case "webhooks":
data.WebhooksData = h.buildWebhooksData(user.DID)
}
// htmx partial: render just the panel.
tmplName := "settings"
if r.Header.Get("HX-Request") == "true" {
tmplName = "settings-panel"
}
if err := h.Templates.ExecuteTemplate(w, tmplName, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
// buildHoldsData resolves the current user's holds for the storage tab.
// Returns: the currently-active hold (if any), non-active member holds, the
// full member-hold list (including active, for selector rendering), and
// eligible holds (the user could join but isn't yet a member of).
func (h *SettingsHandler) buildHoldsData(ctx context.Context, userDID, defaultHold string) (*HoldDisplay, []HoldDisplay, []HoldDisplay, []HoldDisplay) {
if h.DB == nil {
return nil, nil, nil, nil
}
availableHolds, err := db.GetAvailableHolds(h.DB, userDID)
if err != nil {
slog.Warn("Failed to get available holds", "component", "settings", "did", userDID, "error", err)
return nil, nil, nil, nil
}
var activeHold *HoldDisplay
var otherHolds, memberHolds, eligibleHolds []HoldDisplay
for _, hold := range availableHolds {
display := HoldDisplay{
DID: hold.HoldDID,
DisplayName: resolveHoldDisplayName(ctx, &h.BaseUIHandler, hold.HoldDID),
Region: hold.Region,
Membership: hold.Membership,
IsActive: hold.HoldDID == defaultHold,
}
if hold.Permissions != "" {
if err := json.Unmarshal([]byte(hold.Permissions), &display.Permissions); err != nil {
slog.Warn("Failed to parse permissions JSON", "component", "settings", "did", userDID, "hold_did", hold.HoldDID, "error", err)
}
}
// Owners hold all permissions implicitly; crew need blob:write to push.
display.ReadOnly = hold.Membership == "crew" && !slices.Contains(display.Permissions, "blob:write")
if h.HealthChecker != nil {
if status := h.HealthChecker.GetStatus(ctx, hold.HoldDID); status != nil {
if status.Reachable {
display.Status = "online"
} else {
display.Status = "offline"
}
}
}
if hold.Membership == "eligible" {
eligibleHolds = append(eligibleHolds, display)
continue
}
memberHolds = append(memberHolds, display)
if display.IsActive {
holdCopy := display
activeHold = &holdCopy
} else {
otherHolds = append(otherHolds, display)
}
}
return activeHold, otherHolds, memberHolds, eligibleHolds
}
// webhooksTemplateData is the data passed to the webhooks_list template.
type webhooksTemplateData struct {
Webhooks []webhookEntry
Limits webhookLimits
ContainerID string
TriggerInfo []triggerInfo
}
// buildWebhooksData fetches webhook data for SSR in the settings page.
func (h *SettingsHandler) buildWebhooksData(userDID string) webhooksTemplateData {
data := webhooksTemplateData{
ContainerID: "webhooks-content",
TriggerInfo: webhookTriggerInfo(),
}
data.Limits = h.getWebhookLimits(userDID)
webhookList, err := db.ListWebhooks(h.ReadOnlyDB, userDID)
if err != nil {
slog.Warn("Failed to list webhooks for settings SSR", "error", err)
return data
}
data.Webhooks = make([]webhookEntry, len(webhookList))
for i, wh := range webhookList {
flags := webhooks.TriggerFlags(wh.Triggers)
data.Webhooks[i] = webhookEntry{
ID: wh.ID,
Triggers: wh.Triggers,
URL: wh.URL,
HasSecret: wh.HasSecret,
CreatedAt: wh.CreatedAt.Format(time.RFC3339),
HasPush: flags&webhooks.TriggerPush != 0,
HasFirst: flags&webhooks.TriggerFirst != 0,
HasAll: flags&webhooks.TriggerAll != 0,
HasChanged: flags&webhooks.TriggerChanged != 0,
HasQuota: flags&webhooks.TriggerQuota != 0,
Threshold: webhooks.ThresholdPct(wh.Triggers),
}
}
return data
}
// buildSubscriptionDisplay fetches subscription info for SSR in the settings page.
// 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 {
slog.Warn("Failed to get subscription info for settings SSR", "did", userDID, "error", err)
return SubscriptionDisplay{HideBilling: true}
}
if !info.PaymentsEnabled {
return SubscriptionDisplay{HideBilling: true}
}
display := SubscriptionDisplay{
UserDID: info.UserDID,
CurrentTier: info.CurrentTier,
PaymentsEnabled: info.PaymentsEnabled,
SubscriptionID: info.SubscriptionID,
BillingInterval: info.BillingInterval,
}
for _, tier := range info.Tiers {
td := TierDisplay{
ID: tier.ID,
Name: tier.Name,
Description: tier.Description,
Features: tier.Features,
PriceCentsMonthly: tier.PriceCentsMonthly,
PriceCentsYearly: tier.PriceCentsYearly,
IsCurrent: tier.IsCurrent,
}
if tier.PriceCentsMonthly > 0 {
if tier.PriceCentsMonthly%100 == 0 {
td.PriceMonthly = fmt.Sprintf("$%d/mo", tier.PriceCentsMonthly/100)
} else {
td.PriceMonthly = fmt.Sprintf("$%.2f/mo", float64(tier.PriceCentsMonthly)/100.0)
}
}
if tier.PriceCentsYearly > 0 {
if tier.PriceCentsYearly%100 == 0 {
td.PriceYearly = fmt.Sprintf("$%d/yr", tier.PriceCentsYearly/100)
} else {
td.PriceYearly = fmt.Sprintf("$%.2f/yr", float64(tier.PriceCentsYearly)/100.0)
}
}
display.Tiers = append(display.Tiers, td)
}
return display
}
// holdDisplayNameOffline derives a display name for a hold DID without making
// any network call: the decoded domain for did:web, the DID itself otherwise.
// Returns "" for an empty DID.
//
// did:plc values are returned whole. This used to truncate them to 24 chars plus
// an ellipsis, which is shorter than a did:plc and therefore yields a string
// that cannot be resolved back to a hold — actively misleading anywhere the name
// stands in for the identity, such as the privacy page's list of operated
// services. Shortening for display is the template's job.
func holdDisplayNameOffline(did string) string {
if did == "" {
return ""
}
if after, ok := strings.CutPrefix(did, "did:web:"); ok {
if decoded, err := url.QueryUnescape(after); err == nil {
return decoded
}
return after
}
return did
}
// resolveHoldDisplayName resolves a hold DID to a human-readable handle via the
// identity directory, falling back to [holdDisplayNameOffline]. This makes up to
// two sequential network calls, so callers on a request path should cache the
// result rather than resolving per render.
func resolveHoldDisplayName(ctx context.Context, h *BaseUIHandler, did string) string {
if did == "" {
return ""
}
// Try resolving via identity directory
if h.Directory != nil {
parsed, err := syntax.ParseDID(did)
if err == nil {
ident, err := h.Directory.LookupDID(ctx, parsed)
if err == nil && ident.Handle.String() != "handle.invalid" && ident.Handle.String() != "" {
return ident.Handle.String()
}
}
}
return holdDisplayNameOffline(did)
}
// UpdateDefaultHoldHandler handles updating the default hold
type UpdateDefaultHoldHandler struct {
BaseUIHandler
}
func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Accept hold_did (new dropdown) or hold_endpoint (legacy text input)
holdDID := r.FormValue("hold_did")
if holdDID == "" {
holdDID = r.FormValue("hold_endpoint")
}
// Normalize did:web encoding (form URL-decoding can strip %3A → colon)
holdDID = atproto.NormalizeDID(holdDID)
// Validate hold DID if provided and database is available
if holdDID != "" && h.DB != nil {
// Check if user has access to this hold
availableHolds, err := db.GetAvailableHolds(h.DB, user.DID)
if err != nil {
slog.Warn("Failed to validate hold access", "component", "settings", "did", user.DID, "error", err)
// Don't block - fall through to allow the update
} else {
hasAccess := false
for _, hold := range availableHolds {
if hold.HoldDID == holdDID {
hasAccess = true
break
}
}
if !hasAccess {
// hx-swap="none" on the selector form means an inline alert
// would be discarded — route through RenderHTMXError so
// the client-side toast handler fires instead.
RenderHTMXError(w, r, http.StatusForbidden, "You don't have access to this hold", nil)
return
}
}
}
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Fetch existing profile or create new one
profile, err := storage.GetProfile(r.Context(), client)
if err != nil || profile == nil {
// Profile doesn't exist, create new one
profile = atproto.NewSailorProfileRecord(holdDID)
} else {
// Update existing profile
profile.DefaultHold = holdDID
profile.UpdatedAt = time.Now()
}
// Save profile
if err := storage.UpdateProfile(r.Context(), client, profile); err != nil {
RenderHTMXError(w, r, http.StatusInternalServerError, "Couldn't update your default hold", err)
return
}
// Cache default hold DID locally (don't wait for Jetstream roundtrip)
if h.DB != nil {
_ = db.UpdateUserDefaultHold(h.DB, user.DID, holdDID)
// Ensure crew membership on the new hold (auto-registers on open holds)
// and refresh captain/crew cache so badge tiers are available immediately
if holdDID != "" {
go func() {
storage.EnsureCrewMembership(
context.Background(), user.DID, holdDID, middleware.GetGlobalAuthorizer(),
func(ctx context.Context, holdDID string) (string, error) {
return auth.GetOrFetchServiceToken(ctx, h.Refresher, user.DID, holdDID, user.PDSEndpoint)
},
)
refreshCaptainRecord(holdDID, h.DB)
refreshCrewMembership(holdDID, user.DID, h.DB)
}()
}
}
// Fire a success toast via HX-Trigger in addition to the HX-Refresh — the
// page reloads so the user sees the new hold applied, and the toast
// confirms the action took effect.
trigger, _ := json.Marshal(map[string]map[string]string{
"toast": {"message": "Default hold updated", "type": "success"},
})
w.Header().Set("HX-Trigger", string(trigger))
w.Header().Set("HX-Refresh", "true")
w.WriteHeader(http.StatusNoContent)
}
// UpdateAutoRemoveUntaggedHandler handles toggling the auto-remove-untagged setting
type UpdateAutoRemoveUntaggedHandler struct {
BaseUIHandler
}
func (h *UpdateAutoRemoveUntaggedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Create ATProto client with session provider
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Fetch existing profile
profile, err := storage.GetProfile(r.Context(), client)
if err != nil || profile == nil {
http.Error(w, "Failed to fetch profile", http.StatusInternalServerError)
return
}
// Toggle the setting (checkbox sends value when checked, absent when unchecked)
profile.AutoRemoveUntagged = !profile.AutoRemoveUntagged
profile.UpdatedAt = time.Now()
if err := storage.UpdateProfile(r.Context(), client, profile); err != nil {
http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// validOciClients is the set of allowed OCI client values.
// "none" means "image reference only" — no `<client> pull ` prefix.
var validOciClients = map[string]bool{
"docker": true,
"podman": true,
"buildah": true,
"nerdctl": true,
"crane": true,
"none": true,
}
// UpdateOciClientHandler handles updating the preferred OCI client
type UpdateOciClientHandler struct {
BaseUIHandler
}
func (h *UpdateOciClientHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ociClient := r.FormValue("oci_client")
if !validOciClients[ociClient] {
http.Error(w, "Invalid OCI client", http.StatusBadRequest)
return
}
// Create ATProto client with session provider
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Fetch existing profile
profile, err := storage.GetProfile(r.Context(), client)
if err != nil || profile == nil {
http.Error(w, "Failed to fetch profile", http.StatusInternalServerError)
return
}
// Update OCI client preference (store empty string for "docker" as it's the default)
if ociClient == "docker" {
profile.OciClient = ""
} else {
profile.OciClient = ociClient
}
profile.UpdatedAt = time.Now()
if err := storage.UpdateProfile(r.Context(), client, profile); err != nil {
http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError)
return
}
// Cache locally
if h.DB != nil {
_ = db.UpdateUserOciClient(h.DB, user.DID, ociClient)
}
w.WriteHeader(http.StatusNoContent)
}
// UpdateRegistryDomainHandler handles updating the preferred registry domain
type UpdateRegistryDomainHandler struct {
BaseUIHandler
}
func (h *UpdateRegistryDomainHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
registryDomain := r.FormValue("registry_domain")
// Empty means "use the primary domain". Any non-empty value must be one of
// the configured registry domains.
if registryDomain != "" && !slices.Contains(h.RegistryDomains, registryDomain) {
http.Error(w, "Invalid registry domain", http.StatusBadRequest)
return
}
// Store empty string when the primary (first configured) domain is selected,
// so the preference tracks the primary even if the admin reorders domains.
if len(h.RegistryDomains) > 0 && registryDomain == h.RegistryDomains[0] {
registryDomain = ""
}
// Create ATProto client with session provider
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Fetch existing profile
profile, err := storage.GetProfile(r.Context(), client)
if err != nil || profile == nil {
http.Error(w, "Failed to fetch profile", http.StatusInternalServerError)
return
}
profile.RegistryDomain = registryDomain
profile.UpdatedAt = time.Now()
if err := storage.UpdateProfile(r.Context(), client, profile); err != nil {
http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError)
return
}
// Cache locally
if h.DB != nil {
_ = db.UpdateUserRegistryDomain(h.DB, user.DID, registryDomain)
}
w.WriteHeader(http.StatusNoContent)
}
// UpdateAIAdvisorHandler handles toggling the AI Image Advisor setting
type UpdateAIAdvisorHandler struct {
BaseUIHandler
}
func (h *UpdateAIAdvisorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
profile, err := storage.GetProfile(r.Context(), client)
if err != nil || profile == nil {
http.Error(w, "Failed to fetch profile", http.StatusInternalServerError)
return
}
// Toggle: nil/true → false, false → nil (default enabled)
if profile.AIAdvisorEnabled == nil || *profile.AIAdvisorEnabled {
f := false
profile.AIAdvisorEnabled = &f
} else {
profile.AIAdvisorEnabled = nil
}
profile.UpdatedAt = time.Now()
if err := storage.UpdateProfile(r.Context(), client, profile); err != nil {
http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// refreshCaptainRecord fetches a hold's captain record via XRPC and caches it locally.
// This ensures badge tiers and other captain metadata are available immediately
// without waiting for Jetstream or the next backfill cycle.
func refreshCaptainRecord(holdDID string, dbConn *sql.DB) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
if err != nil {
slog.Debug("Failed to resolve hold URL for captain refresh", "hold_did", holdDID, "error", err)
return
}
holdClient := atproto.NewClient(holdURL, holdDID, "")
record, err := holdClient.GetRecord(ctx, "io.atcr.hold.captain", "self")
if err != nil {
slog.Debug("Failed to fetch captain record for refresh", "hold_did", holdDID, "error", err)
return
}
var captainRecord db.HoldCaptainRecord
if err := json.Unmarshal(record.Value, &captainRecord); err != nil {
slog.Debug("Failed to parse captain record for refresh", "hold_did", holdDID, "error", err)
return
}
captainRecord.HoldDID = holdDID
captainRecord.UpdatedAt = time.Now()
if err := db.UpsertCaptainRecord(dbConn, &captainRecord); err != nil {
slog.Debug("Failed to cache captain record on refresh", "hold_did", holdDID, "error", err)
return
}
slog.Info("Refreshed captain record for hold", "hold_did", holdDID)
}
// refreshCrewMembership fetches a user's crew record from a hold and caches it locally.
// Uses the deterministic rkey to do a direct O(1) lookup.
func refreshCrewMembership(holdDID, userDID string, dbConn *sql.DB) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
if err != nil {
slog.Debug("Failed to resolve hold URL for crew refresh", "hold_did", holdDID, "error", err)
return
}
rkey := atproto.CrewRecordKey(userDID)
holdClient := atproto.NewClient(holdURL, holdDID, "")
record, err := holdClient.GetRecord(ctx, atproto.CrewCollection, rkey)
if err != nil {
slog.Debug("No crew record found for user on hold", "hold_did", holdDID, "user_did", userDID, "error", err)
return
}
var crewRecord atproto.CrewRecord
if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
slog.Debug("Failed to parse crew record for refresh", "hold_did", holdDID, "error", err)
return
}
permJSON, _ := json.Marshal(crewRecord.Permissions)
member := &db.CrewMember{
HoldDID: holdDID,
MemberDID: crewRecord.Member,
Rkey: rkey,
Role: crewRecord.Role,
Permissions: string(permJSON),
Tier: crewRecord.Tier,
AddedAt: crewRecord.AddedAt,
}
if err := db.UpsertCrewMember(dbConn, member); err != nil {
slog.Debug("Failed to cache crew membership on refresh", "hold_did", holdDID, "user_did", userDID, "error", err)
return
}
slog.Info("Refreshed crew membership for user on hold", "hold_did", holdDID, "user_did", userDID, "tier", crewRecord.Tier)
}