From 196b47757a3132aaf1c17240b0317798952a9cae Mon Sep 17 00:00:00 2001 From: William Drake Date: Tue, 29 Sep 2026 11:41:33 -0400 Subject: [PATCH 1/4] feat(claude): redeem native limit resets with server-selected grant POST /admin/accounts/:id/claude/reset-credits/redeem (Idempotency-Key required, same middleware chain as the Codex reset-quota route). The server re-queries eligibility and claims only the upstream next_grant_id when it is redeemable; grant/org IDs never reach the client. Claims are serialized by account and organization Redis leases, and a durable per-organization fence in the idempotency table blocks further redemption after an unconfirmed outcome (settles after 24h). Ported from upstream PR #7591. Co-authored-by: korkin25 Co-Authored-By: Claude Opus 5.5 (1M context) --- backend/cmd/server/wire_gen.go | 4 +- .../handler/admin/claude_reset_handler.go | 22 ++ .../admin/claude_reset_handler_test.go | 56 +++ backend/internal/server/routes/admin.go | 2 + .../routes/claude_reset_redeem_routes_test.go | 57 +++ .../internal/service/claude_reset_credits.go | 38 +- .../internal/service/claude_reset_redeem.go | 321 +++++++++++++++++ .../service/claude_reset_redeem_test.go | 329 ++++++++++++++++++ backend/internal/service/wire.go | 10 +- 9 files changed, 833 insertions(+), 6 deletions(-) create mode 100644 backend/internal/handler/admin/claude_reset_handler_test.go create mode 100644 backend/internal/server/routes/claude_reset_redeem_routes_test.go create mode 100644 backend/internal/service/claude_reset_redeem.go create mode 100644 backend/internal/service/claude_reset_redeem_test.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 4394db4132..1eda60da76 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -288,7 +288,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService) upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db) openCodeGoUsageService := service.ProvideOpenCodeGoUsageService(accountRepository, httpUpstream, settingService, leaderLockCache, db) - claudeResetCreditService := service.NewClaudeResetCreditService(accountRepository, claudeTokenProvider, proxyRepository, settingService) + idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig) + claudeResetCreditService := service.ProvideClaudeResetCreditService(accountRepository, claudeTokenProvider, proxyRepository, settingService, idempotencyCoordinator, leaderLockCache) adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, claudeResetCreditService) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) @@ -322,7 +323,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { batchImageDownloadService := service.NewBatchImageDownloadService(batchImageRepository, accountRepository, batchImageDownloadLimiter, configConfig) batchImageCleanupService := service.ProvideBatchImageCleanupService(batchImageRepository, accountRepository, configConfig) batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler) - idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig) idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig) openAIQuotaAutoResetService := service.ProvideOpenAIQuotaAutoResetService(accountRepository, openAIQuotaService, rateLimitService, idempotencyCoordinator, auditLogService, settingService, leaderLockCache) handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService, openAIQuotaAutoResetService) diff --git a/backend/internal/handler/admin/claude_reset_handler.go b/backend/internal/handler/admin/claude_reset_handler.go index 8ec456444a..35cd8e5597 100644 --- a/backend/internal/handler/admin/claude_reset_handler.go +++ b/backend/internal/handler/admin/claude_reset_handler.go @@ -10,6 +10,7 @@ import ( type claudeResetReader interface { Query(context.Context, int64) (*service.ClaudeResetCredits, error) + Redeem(context.Context, int64, string) (*service.ClaudeResetOutcome, error) } func (h *AccountHandler) SetClaudeResetCreditService(s *service.ClaudeResetCreditService) { @@ -32,3 +33,24 @@ func (h *AccountHandler) ClaudeResetCredits(c *gin.Context) { } response.Success(c, status) } + +// RedeemClaudeResetCredit consumes the upstream next reset credit. The request has +// no body: the server picks the grant; the Idempotency-Key header identifies one +// operator confirmation and replays its outcome. +func (h *AccountHandler) RedeemClaudeResetCredit(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + response.BadRequest(c, "Invalid account ID") + return + } + if h.claudeResetCredits == nil { + response.Error(c, 503, "Claude reset service unavailable") + return + } + result, err := h.claudeResetCredits.Redeem(c.Request.Context(), id, c.GetHeader("Idempotency-Key")) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} diff --git a/backend/internal/handler/admin/claude_reset_handler_test.go b/backend/internal/handler/admin/claude_reset_handler_test.go new file mode 100644 index 0000000000..9b28650fe7 --- /dev/null +++ b/backend/internal/handler/admin/claude_reset_handler_test.go @@ -0,0 +1,56 @@ +package admin + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type claudeResetHandlerStub struct { + key string + calls int +} + +func (s *claudeResetHandlerStub) Query(context.Context, int64) (*service.ClaudeResetCredits, error) { + return &service.ClaudeResetCredits{Credits: []service.ClaudeResetCredit{}}, nil +} + +func (s *claudeResetHandlerStub) Redeem(_ context.Context, _ int64, key string) (*service.ClaudeResetOutcome, error) { + s.calls++ + s.key = key + if key == "" { + return nil, service.ErrIdempotencyKeyRequired + } + return &service.ClaudeResetOutcome{Outcome: "reset", Cleared: []string{"five_hour"}}, nil +} + +func TestClaudeResetHandlerRedeemContract(t *testing.T) { + gin.SetMode(gin.TestMode) + stub := &claudeResetHandlerStub{} + h := &AccountHandler{claudeResetCredits: stub} + r := gin.New() + r.POST("/accounts/:id/claude/reset-credits/redeem", h.RedeemClaudeResetCredit) + + req := httptest.NewRequest(http.MethodPost, "/accounts/1/claude/reset-credits/redeem", nil) + req.Header.Set("Idempotency-Key", "same-operation") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "same-operation", stub.key) + require.Contains(t, w.Body.String(), `"outcome":"reset"`) + + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/accounts/1/claude/reset-credits/redeem", nil)) + require.Equal(t, http.StatusBadRequest, w.Code) + require.Contains(t, w.Body.String(), "IDEMPOTENCY_KEY_REQUIRED") + + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/accounts/abc/claude/reset-credits/redeem", nil)) + require.Equal(t, http.StatusBadRequest, w.Code) + require.Equal(t, 2, stub.calls) +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index ab28619b62..315fc20e42 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -368,6 +368,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.PUT("/opencode-go-usage/settings", h.Admin.Account.UpdateOpenCodeGoUsageSettings) accounts.GET("/:id", h.Admin.Account.GetByID) accounts.GET("/:id/claude/reset-credits", h.Admin.Account.ClaudeResetCredits) + // Same protection as the Codex reset-quota route (admin auth, audit, compliance guard). + accounts.POST("/:id/claude/reset-credits/redeem", h.Admin.Account.RedeemClaudeResetCredit) accounts.POST("", h.Admin.Account.Create) accounts.POST("/:id/duplicate", h.Admin.Account.Duplicate) accounts.POST("/check-mixed-channel", h.Admin.Account.CheckMixedChannel) diff --git a/backend/internal/server/routes/claude_reset_redeem_routes_test.go b/backend/internal/server/routes/claude_reset_redeem_routes_test.go new file mode 100644 index 0000000000..b4a9e7adab --- /dev/null +++ b/backend/internal/server/routes/claude_reset_redeem_routes_test.go @@ -0,0 +1,57 @@ +package routes + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/handler" + adminhandler "github.com/Wei-Shaw/sub2api/internal/handler/admin" + servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// The Claude redeem route consumes an irreversible credit just like the Codex +// reset-quota route, so it must sit behind exactly the same middleware chain. +func TestClaudeResetRedeemRouteMatchesCodexResetProtection(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + handlers := &handler.Handlers{Admin: &handler.AdminHandlers{Account: &adminhandler.AccountHandler{}, OpenAIOAuth: &adminhandler.OpenAIOAuthHandler{}}} + chains := map[string][]string{} + adminAuth := servermiddleware.AdminAuthMiddleware(func(c *gin.Context) { + names := c.HandlerNames() + chains[c.FullPath()] = names[:len(names)-1] + if c.GetHeader("Authorization") == "" { + servermiddleware.AbortWithError(c, http.StatusUnauthorized, "UNAUTHORIZED", "Authorization required") + return + } + servermiddleware.AbortWithError(c, http.StatusForbidden, "FORBIDDEN", "Admin access required") + }) + auditLog := servermiddleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() }) + stepUp := servermiddleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() }) + RegisterAdminRoutes(router.Group("/api/v1"), handlers, adminAuth, auditLog, stepUp, nil, nil) + + codex := "/api/v1/admin/openai/accounts/1/reset-quota" + claude := "/api/v1/admin/accounts/1/claude/reset-credits/redeem" + for _, path := range []string{codex, claude} { + for _, auth := range []string{"", "Bearer user-token"} { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, path, nil) + if auth != "" { + request.Header.Set("Authorization", auth) + } + router.ServeHTTP(recorder, request) + if auth == "" { + require.Equal(t, http.StatusUnauthorized, recorder.Code, path) + } else { + require.Equal(t, http.StatusForbidden, recorder.Code, path) + } + } + } + codexChain := chains["/api/v1/admin/openai/accounts/:id/reset-quota"] + claudeChain := chains["/api/v1/admin/accounts/:id/claude/reset-credits/redeem"] + require.NotEmpty(t, codexChain) + require.Equal(t, strings.Join(codexChain, "\n"), strings.Join(claudeChain, "\n")) +} diff --git a/backend/internal/service/claude_reset_credits.go b/backend/internal/service/claude_reset_credits.go index 059aaa4b5e..62b024279e 100644 --- a/backend/internal/service/claude_reset_credits.go +++ b/backend/internal/service/claude_reset_credits.go @@ -76,6 +76,10 @@ type ClaudeResetCreditService struct { settings *SettingService do func(*http.Request, string) (*http.Response, error) now func() time.Time + + // Redemption only; both are mandatory and never fail open. + idempotency *IdempotencyCoordinator + locks LeaderLockCache } func NewClaudeResetCreditService(accounts AccountRepository, tokens *ClaudeTokenProvider, proxies ProxyRepository, settings *SettingService) *ClaudeResetCreditService { @@ -139,6 +143,16 @@ func (s *ClaudeResetCreditService) query(ctx context.Context, id int64) (*Claude if err != nil { return nil, err } + block, err := s.fetchBlock(ctx, token, proxy) + if err != nil { + return nil, err + } + return projectClaudeResetCredits(block, s.now()), nil +} + +// fetchBlock returns the raw cedar_ember block (nil when absent). It carries grant +// IDs, so it must never leave the service. +func (s *ClaudeResetCreditService) fetchBlock(ctx context.Context, token, proxy string) (*claudeResetBlock, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, claudeResetUsageURL, nil) if err != nil { return nil, err @@ -166,7 +180,7 @@ func (s *ClaudeResetCreditService) query(ctx context.Context, id int64) (*Claude return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset grants") } } - return projectClaudeResetCredits(block, s.now()), nil + return block, nil } func (s *ClaudeResetCreditService) Query(ctx context.Context, id int64) (*ClaudeResetCredits, error) { @@ -184,11 +198,11 @@ func projectClaudeResetCredits(b *claudeResetBlock, now time.Time) *ClaudeResetC } r.WeeklyResetsAt = b.WeeklyResetsAt for _, g := range b.Grants { - if !claudeResetGrantIDPattern.MatchString(g.ID) || len(g.Clears) == 0 || g.ResetsLeft <= 0 || g.Paused || (g.StartsAt != nil && now.Before(*g.StartsAt)) || (g.EndsAt != nil && !now.Before(*g.EndsAt)) { + if !claudeResetGrantHeld(g, now) { continue } requires := g.UseRequiresLimit == nil || *g.UseRequiresLimit - usable := b.Eligible && g.UsableNow && g.ID == b.NextGrantID && (!requires || b.AtLimit) && len(g.Blocking) == 0 && (b.CooldownUntil == nil || !now.Before(*b.CooldownUntil)) + usable := claudeResetGrantRedeemable(b, g, now) used := map[string]float64{} for k, v := range g.PercentUsed { if v >= 0 && v <= 100 { @@ -202,3 +216,21 @@ func projectClaudeResetCredits(b *claudeResetBlock, now time.Time) *ClaudeResetC } return r } + +// claudeResetGrantHeld reports whether a grant is a live, well-formed credit. +func claudeResetGrantHeld(g claudeResetGrant, now time.Time) bool { + return claudeResetGrantIDPattern.MatchString(g.ID) && len(g.Clears) > 0 && g.ResetsLeft > 0 && !g.Paused && + (g.StartsAt == nil || !now.Before(*g.StartsAt)) && (g.EndsAt == nil || now.Before(*g.EndsAt)) +} + +// claudeResetGrantRedeemable is the single gate shared by the query projection and +// redemption: only the upstream next grant, usable now, unblocked, outside cooldown, +// and with its at-limit requirement satisfied. +func claudeResetGrantRedeemable(b *claudeResetBlock, g claudeResetGrant, now time.Time) bool { + if b == nil || !claudeResetGrantHeld(g, now) { + return false + } + requires := g.UseRequiresLimit == nil || *g.UseRequiresLimit + return b.Eligible && g.UsableNow && g.ID == b.NextGrantID && (!requires || b.AtLimit) && len(g.Blocking) == 0 && + (b.CooldownUntil == nil || !now.Before(*b.CooldownUntil)) +} diff --git a/backend/internal/service/claude_reset_redeem.go b/backend/internal/service/claude_reset_redeem.go new file mode 100644 index 0000000000..5ac5e34a44 --- /dev/null +++ b/backend/internal/service/claude_reset_redeem.go @@ -0,0 +1,321 @@ +package service + +// Manual redemption of Claude native limit resets. The claim protocol (idempotent +// operation, account + organization leases, durable organization fence) is ported +// from upstream PR #7591 by korkin25, adapted so the server alone selects the grant. + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/google/uuid" +) + +const ( + claudeResetOperationScope = "claude_reset_redeem" + claudeResetFenceScope = "claude_reset_org_fence" + claudeResetProfileURL = "https://api.anthropic.com/api/oauth/profile" + claudeResetRedeemURLFmt = "https://api.anthropic.com/api/organizations/%s/reset_rate_limits" + claudeResetLeaseTTL = 90 * time.Second + claudeResetRecordTTL = 365 * 24 * time.Hour + // An unconfirmed claim blocks every further redemption of the organization until + // the upstream outcome has certainly settled; after that a fresh query is + // authoritative again (a consumed credit shows up as a lower count or cooldown). + claudeResetUnknownFenceTTL = 24 * time.Hour + + ClaudeResetOutcomeReset = "reset" + ClaudeResetOutcomeAlreadyUsed = "already_used" + ClaudeResetOutcomeNotLimited = "not_limited" + ClaudeResetOutcomeCooldown = "cooldown" + ClaudeResetOutcomeIneligible = "ineligible" + ClaudeResetOutcomeUnknown = "unknown" +) + +var claudeResetReasonPattern = regexp.MustCompile(`^[a-z0-9_]{1,64}$`) + +// ClaudeResetOutcome is the sanitized redemption result. It never carries grant, +// organization, or upstream request IDs. +type ClaudeResetOutcome struct { + Outcome string `json:"outcome"` + Reason string `json:"reason,omitempty"` + Cleared []string `json:"cleared,omitempty"` + CooldownUntil *time.Time `json:"cooldown_until,omitempty"` + Credits *ClaudeResetCredits `json:"credits,omitempty"` + Replayed bool `json:"replayed"` +} + +// claudeResetFence is stored in the idempotency table, one row per provider +// organization, so duplicate local accounts share it and account edits cannot erase it. +type claudeResetFence struct { + Operation string `json:"operation"` + Outcome string `json:"outcome"` + Reason string `json:"reason,omitempty"` + At time.Time `json:"at"` +} + +// ConfigureRedemption enables Redeem. Without both stores Redeem refuses to run. +func (s *ClaudeResetCreditService) ConfigureRedemption(idem *IdempotencyCoordinator, locks LeaderLockCache) { + s.idempotency = idem + s.locks = locks +} + +// Redeem consumes the upstream next reset grant of the account, if and only if a +// fresh query shows it redeemable. key identifies one operator confirmation: the +// same key replays the stored outcome and never sends a second claim. +func (s *ClaudeResetCreditService) Redeem(ctx context.Context, id int64, key string) (*ClaudeResetOutcome, error) { + if strings.TrimSpace(key) == "" { + return nil, ErrIdempotencyKeyRequired + } + normalized, err := NormalizeIdempotencyKey(key) + if err != nil { + return nil, err + } + if s.idempotency == nil || s.idempotency.repo == nil || s.locks == nil { + return nil, ErrIdempotencyStoreUnavail + } + // Validate before entering the idempotency scope so a deleted or converted + // account never replays. No token is stored anywhere. + if _, _, _, err = s.account(ctx, id); err != nil { + return nil, err + } + operation := HashIdempotencyKey(fmt.Sprintf("claude-reset:%d:%s", id, normalized)) + result, err := s.idempotency.Execute(ctx, IdempotencyExecuteOptions{ + Scope: claudeResetOperationScope, ActorScope: fmt.Sprintf("account:%d", id), Method: http.MethodPost, + Route: "/admin/accounts/:id/claude/reset-credits/redeem", IdempotencyKey: operation, + Payload: map[string]any{"account_id": id}, TTL: claudeResetRecordTTL, RequireKey: true, ExecutionTimeout: 60 * time.Second, + }, func(exec context.Context) (any, error) { return s.redeemOnce(exec, id, operation) }) + if err != nil { + return nil, err + } + raw, err := json.Marshal(result.Data) + if err != nil { + return nil, err + } + var outcome ClaudeResetOutcome + if err = json.Unmarshal(raw, &outcome); err != nil { + return nil, err + } + outcome.Replayed = outcome.Replayed || result.Replayed + return &outcome, nil +} + +func (s *ClaudeResetCreditService) lease(ctx context.Context, key, owner string) (func(), error) { + acquired, err := s.locks.TryAcquireLeaderLock(ctx, key, owner, claudeResetLeaseTTL) + if err != nil { + return nil, infraerrors.ServiceUnavailable("CLAUDE_RESET_LOCK_UNAVAILABLE", "reset coordination unavailable") + } + if !acquired { + return nil, infraerrors.Conflict("CLAUDE_RESET_BUSY", "another reset is in progress") + } + return func() { + release, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = s.locks.ReleaseLeaderLock(release, key, owner) + }, nil +} + +func (s *ClaudeResetCreditService) redeemOnce(ctx context.Context, id int64, operation string) (*ClaudeResetOutcome, error) { + owner := uuid.NewString() + release, err := s.lease(ctx, fmt.Sprintf("claude:reset-credit:account:%d", id), owner) + if err != nil { + return nil, err + } + defer release() + _, token, proxy, err := s.account(ctx, id) + if err != nil { + return nil, err + } + org, err := s.organization(ctx, token, proxy) + if err != nil { + return nil, err + } + orgHash := HashIdempotencyKey("claude-org:" + org) + releaseOrg, err := s.lease(ctx, "claude:reset-credit:organization:"+orgHash, owner) + if err != nil { + return nil, err + } + defer releaseOrg() + + fence, err := s.loadFence(ctx, orgHash) + if err != nil { + return nil, err + } + var prior claudeResetFence + if fence.ResponseBody != nil { + if json.Unmarshal([]byte(*fence.ResponseBody), &prior) != nil || prior.Operation == "" { + return nil, infraerrors.Conflict("CLAUDE_RESET_UNRESOLVED", "previous reset requires reconciliation") + } + if prior.Operation == operation { + // A crashed or interrupted attempt of this same confirmation: never resend. + return &ClaudeResetOutcome{Outcome: prior.Outcome, Reason: prior.Reason, Replayed: true}, nil + } + if prior.Outcome == ClaudeResetOutcomeUnknown && s.now().Before(prior.At.Add(claudeResetUnknownFenceTTL)) { + return nil, infraerrors.Conflict("CLAUDE_RESET_UNRESOLVED", "previous reset outcome is unconfirmed; redemption is blocked for now") + } + } + + // Fresh eligibility check right before the irreversible call; the server alone + // picks the grant, and only the upstream next grant can qualify. + block, err := s.fetchBlock(ctx, token, proxy) + if err != nil { + return nil, err + } + var grant *claudeResetGrant + if block != nil { + for i := range block.Grants { + if block.Grants[i].ID == block.NextGrantID && claudeResetGrantRedeemable(block, block.Grants[i], s.now()) { + grant = &block.Grants[i] + break + } + } + } + if grant == nil { + return nil, infraerrors.Conflict("CLAUDE_RESET_NOT_AVAILABLE", "no reset is redeemable right now") + } + + // Persist the unknown marker before sending: a crash after this point blocks + // both a resend and another credit until the fence settles. + marker := claudeResetFence{Operation: operation, Outcome: ClaudeResetOutcomeUnknown, Reason: "claim_unconfirmed", At: s.now().UTC()} + if err = s.persistFence(ctx, fence.ID, marker); err != nil { + return nil, err + } + outcome := s.claim(ctx, token, proxy, org, grant.ID, operation) + marker.Outcome, marker.Reason = outcome.Outcome, outcome.Reason + persistCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) + persistErr := s.persistFence(persistCtx, fence.ID, marker) + cancel() + if persistErr != nil { + return &ClaudeResetOutcome{Outcome: ClaudeResetOutcomeUnknown, Reason: "result_persistence_failed"}, nil + } + if outcome.Outcome != ClaudeResetOutcomeUnknown { + if fresh, e := s.fetchBlock(ctx, token, proxy); e == nil { + outcome.Credits = projectClaudeResetCredits(fresh, s.now()) + } + } + return outcome, nil +} + +func (s *ClaudeResetCreditService) loadFence(ctx context.Context, orgHash string) (*IdempotencyRecord, error) { + repo := s.idempotency.repo + fence, err := repo.GetByScopeAndKeyHash(ctx, claudeResetFenceScope, orgHash) + if err != nil { + return nil, ErrIdempotencyStoreUnavail + } + if fence != nil { + return fence, nil + } + row := &IdempotencyRecord{Scope: claudeResetFenceScope, IdempotencyKeyHash: orgHash, RequestFingerprint: orgHash, Status: IdempotencyStatusProcessing, ExpiresAt: s.now().Add(claudeResetRecordTTL)} + if _, err = repo.CreateProcessing(ctx, row); err != nil { + return nil, ErrIdempotencyStoreUnavail + } + fence, err = repo.GetByScopeAndKeyHash(ctx, claudeResetFenceScope, orgHash) + if err != nil || fence == nil { + return nil, ErrIdempotencyStoreUnavail + } + return fence, nil +} + +func (s *ClaudeResetCreditService) persistFence(ctx context.Context, id int64, marker claudeResetFence) error { + body, err := json.Marshal(marker) + if err != nil { + return err + } + if err = s.idempotency.repo.MarkSucceeded(ctx, id, http.StatusOK, string(body), s.now().Add(claudeResetRecordTTL)); err != nil { + return ErrIdempotencyStoreUnavail + } + return nil +} + +func (s *ClaudeResetCreditService) organization(ctx context.Context, token, proxy string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, claudeResetProfileURL, nil) + if err != nil { + return "", err + } + s.headers(ctx, req, token) + resp, err := s.do(req, proxy) + if err != nil { + return "", infraerrors.ServiceUnavailable("CLAUDE_RESET_PROFILE_FAILED", "OAuth profile unavailable") + } + defer func() { _ = resp.Body.Close() }() + var body struct { + Organization struct { + UUID string `json:"uuid"` + } `json:"organization"` + } + if resp.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body) != nil { + return "", infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_PROFILE_FAILED", "OAuth profile unavailable") + } + parsed, err := uuid.Parse(body.Organization.UUID) + if err != nil || parsed == uuid.Nil { + return "", infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_ORGANIZATION_INVALID", "OAuth organization unavailable") + } + return parsed.String(), nil +} + +// claim sends the single irreversible request. Anything but a well-formed, known +// result is reported as unknown so the fence blocks a blind retry. +func (s *ClaudeResetCreditService) claim(ctx context.Context, token, proxy, org, grantID, operation string) *ClaudeResetOutcome { + unknown := &ClaudeResetOutcome{Outcome: ClaudeResetOutcomeUnknown, Reason: "claim_unconfirmed"} + // Deterministic per confirmation (64 hex chars, matches ^[A-Za-z0-9_-]{1,64}$). + body, err := json.Marshal(map[string]string{"program": "cedar_ember", "grant_id": grantID, "request_id": operation}) + if err != nil { + return unknown + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf(claudeResetRedeemURLFmt, org), bytes.NewReader(body)) + if err != nil { + return unknown + } + s.headers(ctx, req, token) + resp, err := s.do(req, proxy) + if err != nil { + return unknown + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return &ClaudeResetOutcome{Outcome: ClaudeResetOutcomeIneligible, Reason: "authorization_rejected"} + } + if resp.StatusCode != http.StatusOK { + return unknown + } + var result struct { + Result string `json:"result"` + Reason string `json:"reason"` + Cleared []string `json:"cleared"` + CooldownUntil *time.Time `json:"cooldown_until"` + } + if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) != nil { + return unknown + } + reason := "" + if claudeResetReasonPattern.MatchString(result.Reason) { + reason = result.Reason + } + if reason == "stamp_indeterminate" || reason == "reset_unconfirmed" { + return unknown + } + switch result.Result { + case ClaudeResetOutcomeReset, ClaudeResetOutcomeAlreadyUsed, ClaudeResetOutcomeNotLimited, ClaudeResetOutcomeCooldown, ClaudeResetOutcomeIneligible: + out := &ClaudeResetOutcome{Outcome: result.Result, Reason: reason, CooldownUntil: result.CooldownUntil} + for _, w := range result.Cleared { + if claudeResetReasonPattern.MatchString(w) { + out.Cleared = append(out.Cleared, w) + } + } + return out + case "unavailable": + if reason == "" { + reason = "upstream_unavailable" + } + return &ClaudeResetOutcome{Outcome: ClaudeResetOutcomeUnknown, Reason: reason} + default: + return unknown + } +} diff --git a/backend/internal/service/claude_reset_redeem_test.go b/backend/internal/service/claude_reset_redeem_test.go new file mode 100644 index 0000000000..8a7da9424c --- /dev/null +++ b/backend/internal/service/claude_reset_redeem_test.go @@ -0,0 +1,329 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" +) + +const redeemTestOrg = "11111111-1111-4111-8111-111111111111" + +type redeemAccountsStub struct{} + +func (redeemAccountsStub) GetByID(_ context.Context, id int64) (*Account, error) { + return &Account{ID: id, Platform: PlatformAnthropic, Type: AccountTypeOAuth, Credentials: map[string]any{"scope": "user:profile"}}, nil +} + +type redeemLeaseStub struct { + mu sync.Mutex + keys map[string]bool + fail bool +} + +func (s *redeemLeaseStub) TryAcquireLeaderLock(_ context.Context, key, _ string, _ time.Duration) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.fail { + return false, errors.New("unavailable") + } + if s.keys == nil { + s.keys = map[string]bool{} + } + if s.keys[key] { + return false, nil + } + s.keys[key] = true + return true, nil +} + +func (s *redeemLeaseStub) ReleaseLeaderLock(_ context.Context, key, _ string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.keys, key) + return nil +} + +type redeemFake struct { + mu sync.Mutex + status string // usage body + claim string // POST body, or "network-error" + claimHTTP int + posts []map[string]string + gate chan struct{} // when set, POST blocks until closed + entered chan struct{} +} + +func (f *redeemFake) postCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.posts) +} + +const redeemableStatus = `{"cedar_ember":{"eligible":true,"at_limit":true,"next_grant_id":"grant_next","grants":[` + + `{"id":"grant_other","resets_left":1,"usable_now":true,"use_requires_limit":false,"clears":["five_hour"]},` + + `{"id":"grant_next","resets_left":2,"usable_now":true,"use_requires_limit":true,"clears":["five_hour","seven_day"]}]}}` + +func newRedeemService(t *testing.T, f *redeemFake) (*ClaudeResetCreditService, *inMemoryIdempotencyRepo, *redeemLeaseStub) { + t.Helper() + repo := newInMemoryIdempotencyRepo() + cfg := DefaultIdempotencyConfig() + cfg.FailedRetryBackoff = 0 + locks := &redeemLeaseStub{} + s := &ClaudeResetCreditService{accounts: redeemAccountsStub{}, tokens: resetTokenStub{}, now: time.Now} + s.ConfigureRedemption(NewIdempotencyCoordinator(repo, cfg), locks) + if f.status == "" { + f.status = redeemableStatus + } + s.do = func(r *http.Request, _ string) (*http.Response, error) { + require.Equal(t, "Bearer synthetic-token", r.Header.Get("Authorization")) + code := http.StatusOK + var body string + switch { + case r.Method == http.MethodGet && r.URL.String() == claudeResetProfileURL: + body = `{"organization":{"uuid":"` + redeemTestOrg + `"}}` + case r.Method == http.MethodGet && r.URL.String() == claudeResetUsageURL: + f.mu.Lock() + body = f.status + f.mu.Unlock() + case r.Method == http.MethodPost: + require.Equal(t, "https://api.anthropic.com/api/organizations/"+redeemTestOrg+"/reset_rate_limits", r.URL.String()) + var payload map[string]string + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + require.Regexp(t, `^[A-Za-z0-9_-]{1,64}$`, payload["request_id"]) + f.mu.Lock() + f.posts = append(f.posts, payload) + f.mu.Unlock() + if f.entered != nil { + f.entered <- struct{}{} + } + if f.gate != nil { + <-f.gate + } + if f.claim == "network-error" { + return nil, errors.New("timeout private upstream details") + } + body = f.claim + if f.claimHTTP != 0 { + code = f.claimHTTP + } + default: + t.Fatalf("unexpected upstream request %s %s", r.Method, r.URL) + } + return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body))}, nil + } + return s, repo, locks +} + +func TestClaudeResetRedeemHappyPathServerPicksNextGrant(t *testing.T) { + f := &redeemFake{claim: `{"result":"reset","resets_left":1,"cleared":["five_hour","seven_day"]}`} + s, _, _ := newRedeemService(t, f) + out, err := s.Redeem(context.Background(), 1, "op-1") + require.NoError(t, err) + require.Equal(t, ClaudeResetOutcomeReset, out.Outcome) + require.False(t, out.Replayed) + require.Equal(t, []string{"five_hour", "seven_day"}, out.Cleared) + require.NotNil(t, out.Credits) + require.Equal(t, 1, f.postCount()) + require.Equal(t, "grant_next", f.posts[0]["grant_id"]) + require.Equal(t, "cedar_ember", f.posts[0]["program"]) + + raw, err := json.Marshal(out) + require.NoError(t, err) + for _, secret := range []string{"grant_next", "grant_other", redeemTestOrg, "synthetic-token", f.posts[0]["request_id"], `"id"`} { + require.NotContains(t, string(raw), secret) + } +} + +func TestClaudeResetRedeemNonRedeemableSendsNoPost(t *testing.T) { + future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + cases := map[string]string{ + "not at limit": strings.Replace(redeemableStatus, `"at_limit":true`, `"at_limit":false`, 1), + "ineligible": strings.Replace(redeemableStatus, `"eligible":true`, `"eligible":false`, 1), + "cooldown": strings.Replace(redeemableStatus, `"eligible":true,`, `"eligible":true,"cooldown_until":"`+future+`",`, 1), + "no next grant": strings.Replace(redeemableStatus, `"next_grant_id":"grant_next"`, `"next_grant_id":"missing"`, 1), + "blocked": strings.Replace(redeemableStatus, `"clears":["five_hour","seven_day"]`, `"clears":["five_hour","seven_day"],"blocking":["x"]`, 1), + "paused": strings.Replace(redeemableStatus, `"id":"grant_next",`, `"id":"grant_next","paused":true,`, 1), + "not usable now": strings.Replace(redeemableStatus, `"id":"grant_next","resets_left":2,"usable_now":true`, `"id":"grant_next","resets_left":2,"usable_now":false`, 1), + "expired": strings.Replace(redeemableStatus, `"id":"grant_next",`, `"id":"grant_next","ends_at":"2000-01-01T00:00:00Z",`, 1), + "not started": strings.Replace(redeemableStatus, `"id":"grant_next",`, `"id":"grant_next","starts_at":"`+future+`",`, 1), + "no resets left": strings.Replace(redeemableStatus, `"id":"grant_next","resets_left":2`, `"id":"grant_next","resets_left":0`, 1), + "no program": `{"cedar_ember":null}`, + } + for name, status := range cases { + t.Run(name, func(t *testing.T) { + require.NotEqual(t, redeemableStatus, status) + f := &redeemFake{status: status, claim: `{"result":"reset"}`} + s, _, _ := newRedeemService(t, f) + _, err := s.Redeem(context.Background(), 1, "op-1") + require.Error(t, err) + require.Equal(t, "CLAUDE_RESET_NOT_AVAILABLE", infraerrors.Reason(err)) + require.Zero(t, f.postCount()) + }) + } +} + +func TestClaudeResetRedeemRequiresKeyAndStores(t *testing.T) { + f := &redeemFake{claim: `{"result":"reset"}`} + s, _, locks := newRedeemService(t, f) + _, err := s.Redeem(context.Background(), 1, " ") + require.ErrorIs(t, err, ErrIdempotencyKeyRequired) + locks.fail = true + _, err = s.Redeem(context.Background(), 1, "op-1") + require.Error(t, err) + unconfigured := &ClaudeResetCreditService{accounts: redeemAccountsStub{}, tokens: resetTokenStub{}, now: time.Now} + _, err = unconfigured.Redeem(context.Background(), 1, "op-2") + require.ErrorIs(t, err, ErrIdempotencyStoreUnavail) + require.Zero(t, f.postCount()) +} + +func TestClaudeResetRedeemSameKeyReplaysWithoutSecondPost(t *testing.T) { + f := &redeemFake{claim: `{"result":"reset"}`} + s, _, _ := newRedeemService(t, f) + first, err := s.Redeem(context.Background(), 1, "op-1") + require.NoError(t, err) + again, err := s.Redeem(context.Background(), 1, "op-1") + require.NoError(t, err) + require.True(t, again.Replayed) + require.Equal(t, first.Outcome, again.Outcome) + require.Equal(t, 1, f.postCount()) + + // A new confirmation is a new operation with a new upstream request_id. + _, err = s.Redeem(context.Background(), 1, "op-2") + require.NoError(t, err) + require.Equal(t, 2, f.postCount()) + require.NotEqual(t, f.posts[0]["request_id"], f.posts[1]["request_id"]) +} + +func TestClaudeResetRedeemDuplicateOrgAccountsConcurrentOnlyOnePost(t *testing.T) { + f := &redeemFake{claim: `{"result":"reset"}`, gate: make(chan struct{}), entered: make(chan struct{}, 2)} + s, _, _ := newRedeemService(t, f) + type res struct { + out *ClaudeResetOutcome + err error + } + first := make(chan res, 1) + go func() { + out, err := s.Redeem(context.Background(), 1, "account-one") + first <- res{out, err} + }() + <-f.entered // account 1 holds the organization lease and is mid-claim + _, err := s.Redeem(context.Background(), 2, "account-two") + require.Error(t, err) + require.Equal(t, "CLAUDE_RESET_BUSY", infraerrors.Reason(err)) + close(f.gate) + r := <-first + require.NoError(t, r.err) + require.Equal(t, ClaudeResetOutcomeReset, r.out.Outcome) + require.Equal(t, 1, f.postCount()) +} + +func TestClaudeResetRedeemUnknownOutcomeFencesOrganization(t *testing.T) { + for _, claim := range []string{"network-error", `{broken`, `{"result":"weird"}`, `{"result":"unavailable"}`, `{"result":"reset","reason":"reset_unconfirmed"}`, "http-500"} { + t.Run(claim, func(t *testing.T) { + f := &redeemFake{claim: claim} + if claim == "http-500" { + f.claim, f.claimHTTP = `{"result":"reset"}`, http.StatusInternalServerError + } + s, _, _ := newRedeemService(t, f) + out, err := s.Redeem(context.Background(), 1, "op-1") + require.NoError(t, err) + require.Equal(t, ClaudeResetOutcomeUnknown, out.Outcome) + require.Nil(t, out.Credits) + require.NotContains(t, out.Reason, "private") + + // Same confirmation replays, no resend. + again, err := s.Redeem(context.Background(), 1, "op-1") + require.NoError(t, err) + require.True(t, again.Replayed) + require.Equal(t, ClaudeResetOutcomeUnknown, again.Outcome) + + // Simulated restart (fresh leases); a new confirmation on this or a + // duplicate account of the same organization is blocked by the fence. + s.locks = &redeemLeaseStub{} + for _, id := range []int64{1, 2} { + _, err = s.Redeem(context.Background(), id, "op-new") + require.Error(t, err) + require.Equal(t, "CLAUDE_RESET_UNRESOLVED", infraerrors.Reason(err)) + } + require.Equal(t, 1, f.postCount()) + + // Once the fence has settled, a fresh query is authoritative again. + s.now = func() time.Time { return time.Now().Add(claudeResetUnknownFenceTTL + time.Minute) } + f.claim, f.claimHTTP = `{"result":"reset"}`, 0 + out, err = s.Redeem(context.Background(), 1, "op-later") + require.NoError(t, err) + require.Equal(t, ClaudeResetOutcomeReset, out.Outcome) + require.Equal(t, 2, f.postCount()) + }) + } +} + +func TestClaudeResetRedeemCrashAfterMarkerNeverResends(t *testing.T) { + f := &redeemFake{claim: `{"result":"reset"}`} + s, _, _ := newRedeemService(t, f) + // Simulate a process that persisted the marker for op-1 and died mid-claim. + orgHash := HashIdempotencyKey("claude-org:" + redeemTestOrg) + fence, err := s.loadFence(context.Background(), orgHash) + require.NoError(t, err) + op := HashIdempotencyKey("claude-reset:1:op-1") + require.NoError(t, s.persistFence(context.Background(), fence.ID, claudeResetFence{Operation: op, Outcome: ClaudeResetOutcomeUnknown, Reason: "claim_unconfirmed", At: time.Now()})) + out, err := s.Redeem(context.Background(), 1, "op-1") + require.NoError(t, err) + require.Equal(t, ClaudeResetOutcomeUnknown, out.Outcome) + require.True(t, out.Replayed) + require.Zero(t, f.postCount()) +} + +func TestClaudeResetRedeemMapsUpstreamResults(t *testing.T) { + cases := []struct { + claim, outcome string + http int + }{ + {`{"result":"reset"}`, ClaudeResetOutcomeReset, 0}, + {`{"result":"already_used"}`, ClaudeResetOutcomeAlreadyUsed, 0}, + {`{"result":"not_limited"}`, ClaudeResetOutcomeNotLimited, 0}, + {`{"result":"cooldown","cooldown_until":"2099-01-01T00:00:00Z"}`, ClaudeResetOutcomeCooldown, 0}, + {`{"result":"ineligible","reason":"plan_changed"}`, ClaudeResetOutcomeIneligible, 0}, + {`{"result":"unavailable","reason":"stamp_indeterminate"}`, ClaudeResetOutcomeUnknown, 0}, + {`{}`, ClaudeResetOutcomeIneligible, http.StatusForbidden}, + } + for _, tc := range cases { + t.Run(tc.claim, func(t *testing.T) { + f := &redeemFake{claim: tc.claim, claimHTTP: tc.http} + s, _, _ := newRedeemService(t, f) + out, err := s.Redeem(context.Background(), 1, "op-1") + require.NoError(t, err) + require.Equal(t, tc.outcome, out.Outcome) + if tc.outcome == ClaudeResetOutcomeCooldown { + require.NotNil(t, out.CooldownUntil) + } + if tc.outcome == ClaudeResetOutcomeIneligible && tc.http == 0 { + require.Equal(t, "plan_changed", out.Reason) + } + // Definite outcomes never block a later confirmation. + if tc.outcome != ClaudeResetOutcomeUnknown { + _, err = s.Redeem(context.Background(), 1, "op-2") + require.NoError(t, err) + require.Equal(t, 2, f.postCount()) + } + }) + } +} + +func TestClaudeResetRedeemSanitizesUpstreamReason(t *testing.T) { + f := &redeemFake{claim: `{"result":"ineligible","reason":"Bearer synthetic-token leaked