fix(claude): harden reset credit projection and cell state (review R2)

- Drop selection_token (unsalted hash of a short grant ID, reversible) and
  claudeGrantSelection from the read-only DTO; assert it never serializes.
- query() returns only the projected result; no raw upstream block escapes.
- Clear a cooldown_until that is already past, using the same `now` as the
  redeemable check; the cell also shows the cooldown only when in the future.
- Invalidate in-flight queries when account id, platform or type changes.
- Sort the earliest-expiry fallback by parsed timestamp, invalid/missing last.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
William Drake
2026-09-28 20:59:39 -04:00
co-authored by Claude Opus 5.5
parent 35232c3949
commit 860782e6fb
5 changed files with 82 additions and 31 deletions
@@ -2,8 +2,6 @@ package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -22,7 +20,6 @@ const claudeResetUsageURL = "https://api.anthropic.com/api/oauth/usage?cedar_emb
// ClaudeResetCredit is deliberately free of upstream grant and organization IDs.
type ClaudeResetCredit struct {
SelectionToken string `json:"selection_token"`
Label string `json:"label"`
ResetsLeft int `json:"resets_left"`
StartsAt *time.Time `json:"starts_at,omitempty"`
@@ -137,50 +134,43 @@ func (s *ClaudeResetCreditService) headers(ctx context.Context, req *http.Reques
req.Header.Set("User-Agent", "claude-cli/"+s.settings.GetClaudeCodeClientVersion(ctx)+" (external, cli)")
}
func (s *ClaudeResetCreditService) query(ctx context.Context, id int64) (*ClaudeResetCredits, *claudeResetBlock, error) {
func (s *ClaudeResetCreditService) query(ctx context.Context, id int64) (*ClaudeResetCredits, error) {
_, token, proxy, err := s.account(ctx, id)
if err != nil {
return nil, nil, err
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, claudeResetUsageURL, nil)
if err != nil {
return nil, nil, err
return nil, err
}
s.headers(ctx, req, token)
resp, err := s.do(req, proxy)
if err != nil {
return nil, nil, infraerrors.ServiceUnavailable("CLAUDE_RESET_QUERY_FAILED", "reset status request failed")
return nil, infraerrors.ServiceUnavailable("CLAUDE_RESET_QUERY_FAILED", "reset status request failed")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_QUERY_FAILED", fmt.Sprintf("reset status upstream HTTP %d", resp.StatusCode))
return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_QUERY_FAILED", fmt.Sprintf("reset status upstream HTTP %d", resp.StatusCode))
}
var envelope map[string]json.RawMessage
if err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&envelope); err != nil || envelope == nil {
return nil, nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset status")
return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset status")
}
if _, ok := envelope["error"]; ok {
return nil, nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset status")
return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset status")
}
var block *claudeResetBlock
raw, present := envelope["cedar_ember"]
if present && string(raw) != "null" {
if err = json.Unmarshal(raw, &block); err != nil || block == nil || block.Grants == nil {
return nil, nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset grants")
return nil, infraerrors.New(http.StatusBadGateway, "CLAUDE_RESET_STATUS_INVALID", "invalid reset grants")
}
}
result := projectClaudeResetCredits(block, s.now())
return result, block, nil
return projectClaudeResetCredits(block, s.now()), nil
}
func (s *ClaudeResetCreditService) Query(ctx context.Context, id int64) (*ClaudeResetCredits, error) {
r, _, e := s.query(ctx, id)
return r, e
}
func claudeGrantSelection(g claudeResetGrant) string {
h := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", g.ID, g.ResetsLeft)))
return hex.EncodeToString(h[:])
return s.query(ctx, id)
}
func projectClaudeResetCredits(b *claudeResetBlock, now time.Time) *ClaudeResetCredits {
@@ -189,7 +179,9 @@ func projectClaudeResetCredits(b *claudeResetBlock, now time.Time) *ClaudeResetC
return r
}
r.Eligible = b.Eligible
r.CooldownUntil = b.CooldownUntil
if b.CooldownUntil != nil && now.Before(*b.CooldownUntil) {
r.CooldownUntil = b.CooldownUntil
}
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)) {
@@ -203,7 +195,7 @@ func projectClaudeResetCredits(b *claudeResetBlock, now time.Time) *ClaudeResetC
used[k] = v
}
}
r.Credits = append(r.Credits, ClaudeResetCredit{SelectionToken: claudeGrantSelection(g), Label: g.Label, ResetsLeft: g.ResetsLeft, StartsAt: g.StartsAt, ExpiresAt: g.EndsAt, Clears: g.Clears, PercentUsed: used, Blocking: g.Blocking, UseRequiresLimit: requires, Redeemable: usable})
r.Credits = append(r.Credits, ClaudeResetCredit{Label: g.Label, ResetsLeft: g.ResetsLeft, StartsAt: g.StartsAt, ExpiresAt: g.EndsAt, Clears: g.Clears, PercentUsed: used, Blocking: g.Blocking, UseRequiresLimit: requires, Redeemable: usable})
if usable {
r.AvailableCount += g.ResetsLeft
}
@@ -40,6 +40,19 @@ func TestClaudeResetStatusNativeContract(t *testing.T) {
require.NotContains(t, string(b), `"id"`)
require.NotContains(t, string(b), "launch")
require.NotContains(t, string(b), "synthetic-token")
require.NotContains(t, string(b), "selection_token")
}
func TestClaudeResetPastCooldownIsCleared(t *testing.T) {
now := time.Now()
past := now.Add(-time.Minute)
no := false
g := claudeResetGrant{ID: "grant", ResetsLeft: 1, UsableNow: true, UseRequiresLimit: &no, Clears: []string{"five_hour"}}
r := projectClaudeResetCredits(&claudeResetBlock{Eligible: true, NextGrantID: g.ID, CooldownUntil: &past, Grants: []claudeResetGrant{g}}, now)
require.Nil(t, r.CooldownUntil)
require.Equal(t, 1, r.AvailableCount)
future := now.Add(time.Hour)
r = projectClaudeResetCredits(&claudeResetBlock{Eligible: true, NextGrantID: g.ID, CooldownUntil: &future, Grants: []claudeResetGrant{g}}, now)
require.Equal(t, &future, r.CooldownUntil)
}
func TestClaudeResetEligibilityFailClosed(t *testing.T) {
now := time.Now()
@@ -1,7 +1,6 @@
import { apiClient } from '../client'
export interface ClaudeResetCredit {
selection_token: string
label: string
resets_left: number
starts_at?: string
@@ -36,7 +36,7 @@
</button>
</div>
<div v-if="visible && status && (primaryCredit || !status.eligible || status.cooldown_until)" class="flex flex-wrap items-center gap-1">
<div v-if="visible && status && (primaryCredit || !status.eligible || cooldownActive)" class="flex flex-wrap items-center gap-1">
<span
v-if="primaryCredit?.expires_at"
data-testid="claude-reset-expiry"
@@ -55,8 +55,8 @@
<span v-if="!status.eligible" class="text-[10px] text-amber-600 dark:text-amber-400">
{{ t('admin.accounts.claudeResetCredits.ineligible') }}
</span>
<span v-if="status.cooldown_until" class="text-[10px] text-amber-600 dark:text-amber-400">
{{ t('admin.accounts.claudeResetCredits.cooldown', { time: formatTime(status.cooldown_until, 'short') }) }}
<span v-if="cooldownActive" data-testid="claude-reset-cooldown" class="text-[10px] text-amber-600 dark:text-amber-400">
{{ t('admin.accounts.claudeResetCredits.cooldown', { time: formatTime(status.cooldown_until!, 'short') }) }}
</span>
</div>
@@ -81,7 +81,7 @@ let generation = 0
const visible = computed(() => props.account.platform === 'anthropic' && props.account.type === 'oauth')
watch(() => props.account.id, () => { generation++; status.value = null; loading.value = false; error.value = false })
watch(() => [props.account.id, props.account.platform, props.account.type], () => { generation++; status.value = null; loading.value = false; error.value = false })
// 与 OpenAIQuotaResetCell 的到期时间格式保持一致
const formatTime = (value: string, style: 'short' | 'full'): string => {
@@ -98,7 +98,19 @@ const totalResets = computed(() => (status.value?.credits ?? []).reduce((sum, c)
// 优先展示下一张会被使用的券(仅它可能 redeemable),否则取最早到期的一张
const primaryCredit = computed(() => {
const credits = status.value?.credits ?? []
return credits.find(c => c.redeemable) ?? [...credits].sort((a, b) => (a.expires_at ?? '9999').localeCompare(b.expires_at ?? '9999'))[0] ?? null
return credits.find(c => c.redeemable) ?? [...credits].sort((a, b) => expiryMs(a.expires_at) - expiryMs(b.expires_at))[0] ?? null
})
// 缺失或无法解析的到期时间排在最后
function expiryMs(value?: string): number {
const ms = value ? new Date(value).getTime() : NaN
return Number.isNaN(ms) ? Number.POSITIVE_INFINITY : ms
}
// 仅在冷却时间仍在未来时提示(后端已清理过期值,这里防御性再判断一次)
const cooldownActive = computed(() => {
const until = status.value?.cooldown_until
return !!until && new Date(until).getTime() > Date.now()
})
const creditTitle = computed(() => {
@@ -7,7 +7,7 @@ vi.mock('@/api/admin/claudeResetCredits', () => ({ getClaudeResetCredits: getCre
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
const account = { id: 1, platform: 'anthropic', type: 'oauth' } as Account
const credit = {
selection_token: 'tok', label: 'Launch reset', resets_left: 1,
label: 'Launch reset', resets_left: 1,
expires_at: '2026-10-22T16:00:00Z', clears: ['five_hour', 'seven_day'],
percent_used: {}, blocking: [], use_requires_limit: false, redeemable: true
}
@@ -31,8 +31,8 @@ describe('Claude reset credit status', () => {
})
it('counts held resets and prefers the next redeemable grant', async () => {
const later = { ...credit, selection_token: 'a', label: 'Later', expires_at: '2026-12-01T00:00:00Z', redeemable: false }
const next = { ...credit, selection_token: 'b', label: 'Next', expires_at: '2026-11-01T00:00:00Z', redeemable: true }
const later = { ...credit, label: 'Later', expires_at: '2026-12-01T00:00:00Z', redeemable: false }
const next = { ...credit, label: 'Next', expires_at: '2026-11-01T00:00:00Z', redeemable: true }
getCredits.mockResolvedValue({ ...snapshot, available_count: 1, credits: [later, next] })
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
await countButton(wrapper).trigger('click')
@@ -72,4 +72,39 @@ describe('Claude reset credit status', () => {
expect(wrapper.find('[data-testid="local-query"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="claude-reset-expiry"]').exists()).toBe(false)
})
it('falls back to the earliest parsed expiry across offsets, invalid last', async () => {
const a = { ...credit, label: 'Tokyo', expires_at: '2026-11-01T08:00:00+09:00', redeemable: false }
const b = { ...credit, label: 'Chicago', expires_at: '2026-10-31T20:00:00-05:00', redeemable: false }
const bad = { ...credit, label: 'Bad', expires_at: 'not-a-date', redeemable: false }
const none = { ...credit, label: 'None', expires_at: undefined, redeemable: false }
getCredits.mockResolvedValue({ ...snapshot, available_count: 0, credits: [none, bad, b, a] })
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
await countButton(wrapper).trigger('click')
await flushPromises()
expect(wrapper.get('[data-testid="claude-reset-expiry"]').attributes('title')).toContain('Tokyo')
expect(wrapper.get('[data-testid="claude-reset-expiry"]').attributes('title')).not.toContain('Chicago')
})
it('hides a cooldown hint that is already in the past', async () => {
getCredits.mockResolvedValue({ ...snapshot, cooldown_until: '2000-01-01T00:00:00Z' })
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
await countButton(wrapper).trigger('click')
await flushPromises()
expect(wrapper.find('[data-testid="claude-reset-cooldown"]').exists()).toBe(false)
})
it('discards in-flight responses when the same account changes type', async () => {
let resolve!: (value: typeof snapshot) => void
getCredits.mockReturnValue(new Promise(r => { resolve = r }))
const wrapper = mount(ClaudeResetCreditsCell, { props: { account } })
await countButton(wrapper).trigger('click')
await wrapper.setProps({ account: { ...account, type: 'setup-token' } })
await wrapper.setProps({ account: { ...account } })
resolve(snapshot)
await flushPromises()
expect(countButton(wrapper).text()).toBe('admin.accounts.claudeResetCredits.count')
expect(countButton(wrapper).attributes('disabled')).toBeUndefined()
expect(wrapper.find('[data-testid="claude-reset-expiry"]').exists()).toBe(false)
})
})