OAuth/setup-token accounts now use per-platform buckets
(oauth:anthropic, oauth:antigravity) instead of a single shared
"oauth" bucket. Prevents cross-contamination between platforms
whose thinking signatures are incompatible.
BucketFor() now takes a platform parameter. All call sites
(harvester, rectifier factory, gateway service) updated.
Log when signatures are replaced from the pool during rectification,
including account_id, bucket, pool_size, and replacement count.
Helps operators verify the pool-replace strategy is working.
Rewrite harvester to be fully decoupled from the main read path:
- Read() copies chunks to a buffered channel (non-blocking)
- Background goroutine parses for signatures independently
- sync.Once prevents double-close panic
- defer recover() in Read() guards send-on-closed-channel
- ctx.Done() arm prevents goroutine leaks on abandoned responses
- Panic recovery with slog.Warn logging
Fix signature extraction: the Anthropic API sends signatures via
content_block_delta with delta.type="signature_delta", not in
content_block_start (which has an empty signature field). Support
both shapes for compatibility.
Add 17 comprehensive tests including signature_delta, panic
isolation, double-close, context cancellation, text containing
"signature" word, and no-thinking streams.
The Anthropic API sends thinking signatures via content_block_delta
with delta.type="signature_delta", not in the content_block_start
event (which has an empty signature field). The harvester was only
checking content_block_start, so it never captured any signatures
and the pool remained permanently empty.
Now handles both content_block_start (legacy compat) and
content_block_delta/signature_delta (current API behavior).
Replace the 4-file service-layer cleanup with the upstream PR's simpler
transaction-level DELETE in accountRepository.Delete.
Bump version to 0.1.114.13.
Accounts use soft-delete (setting deleted_at), so PostgreSQL's
ON DELETE CASCADE on scheduled_test_plans.account_id never fires.
This leaves orphaned plans that continue executing and cannot be
managed from the frontend since the account no longer exists.
ClosesWei-Shaw/sub2api#1728
Add quota exceeded check to IsSchedulable() and refactor
shouldClearStickySession to delegate to IsSchedulable(), eliminating
duplicated logic and fixing missed overload/rate-limit/expired checks.
Frontend displays quota exceeded status independently via quota fields.
Review fixes (P0/P1/P2):
- P0: move nil receiver guards before stage check in PoolClaudeRectifier
and PoolAntigravityRectifier to prevent panic on nil receiver
- P1: add explicit bedrock/upstream cases to BucketFor for self-documenting
intent (return empty bucket = no pool participation)
- P2: replace harvester emit de-dupe from O(N²) [][]byte scan to O(1) map
- P2: add lineBufCap (256KB) to SSE line accumulator to prevent unbounded
growth from malformed upstream streams without newlines
New test cases (29 → 39 total in signature package):
- MultipleAssistantMessages: verify replacement across user/assistant mix
- MalformedContentSkipped: partial JSON doesn't panic or corrupt counter
- SSE_SignatureSplitAcrossReads: line split across multiple Read calls
- SSE_LineBufCapTruncatesHugeLine: lineBuf cap prevents OOM
- NilRequestNoop: PoolAntigravityRectifier with nil Request
- NilReceiverNoPanic: PoolClaudeRectifier nil receiver
- StripAntigravityRectifier_BothStages: verify both stages call strip func
Covers the Lua script logic (ZADD + lazy TTL expiry + capacity trim)
via in-process miniredis — no Docker required, runs under `go test
-tags unit`.
10 cases: basic add/topN, fewer-than-N, empty bucket, capacity
trim to newest 3, lazy expiry on Add, TopN-does-not-filter-by-TTL
(design rule: stale sigs survive until next Add), duplicate score
update, empty-input noop, zero-N guard, Size accuracy.
Also keeps the integration test file (build tag `integration`) for
environments with Docker + testcontainers.
Covers:
- ReplaceThinkingSignaturesInBody / InClaudeRequest:
M>N cycling, empty pool, no thinking blocks, empty signature
preservation, string-content messages, nil pool guard
- Strip / Pool rectifier strategies:
Strip stage-1 unconditional, stage-2 gated on tool error;
Pool empty→proceed=false (rule A), no replacements→abort,
one-shot semantics (stage-2 always declines), pool error
treated as empty
- BucketFor: oauth/setup-token share, apikey per-id, unknown empty
- Harvester SSE: content_block_start extraction, per-response
dedupe, Skip callback, bucket/capacity guards
- Harvester non-streaming: buffers until Close then parses once
Along the way caught and fixed a path-construction bug in
ReplaceThinkingSignaturesInBody: it used gjson.ForEach's key.Raw
which is empty for array indices, producing malformed sjson paths
like "messages..content..signature". Switched to manual index
counters so paths are always well-formed integers.
Adds a Signature Pool Size numeric input to the Rectifier settings
section with a hint explaining the 0-vs-positive semantics. When
pool size > 0, the Thinking Signature and API Key Signature toggle
hints dynamically switch from "strip signatures and retry" to
"replace with pooled signatures (pass through when pool is empty)",
matching the runtime strategy swap.
Includes both zh and en locale keys (poolSize, poolSizeHint,
poolSizeUnit, thinkingSignatureHintPool, apikeySignatureHintPool)
plus the signature_pool_size field in the TypeScript RectifierSettings
interface and save payload.
Introduces the pool-replace retry strategy end-to-end. When
RectifierSettings has SignaturePoolSize>0 and the account's per-type
sub-switch is on, the factory returns a PoolClaudeRectifier /
PoolAntigravityRectifier that fetches the freshest signatures from the
Redis pool and cycles them through the request's thinking blocks
(M>N cycling). Rule A: an empty pool transparently passes the
original upstream error back — no fallback to strip.
Key pieces:
- PoolClaudeRectifier / PoolAntigravityRectifier in internal/service/signature
- signatureRectifierFactory in the service package selects strategy per
request via shouldUsePool(ctx, account) which implements the agreed
decision table (Enabled + SignaturePoolSize>0 + per-type sub-switch)
- WrapResponseBody helper on the factory is invoked at each Claude-native
DoWithTLS entry point (main Forward, Anthropic passthrough, Bedrock) to
run the Harvester over the upstream body; no-op when pool disabled
- ctxkey.IsSignatureRectifyRetry is set on all retry contexts (Claude
two-stage, count_tokens, Antigravity signature retries) so the harvester
skips ingesting signatures from retry responses and does not pollute the
pool with values we ourselves injected
- NewGatewayService / NewAntigravityGatewayService now accept
signature.SignaturePool; wire_gen.go updated accordingly
Antigravity responses are raw Gemini format so WrapResponseBody is not
called there — harvesting stays Claude-only as designed. Antigravity can
still *read* from the shared OAuth pool on retry; whether cross-ecosystem
signatures verify upstream is the question the future PoC will answer.
Introduces the building blocks for the thinking-signature pool feature
without activating any runtime behavior. Nothing uses these new types
yet — Phase 3 will wire them into the retry loops.
New package internal/service/signature adds:
- SignaturePool interface + Bucket helpers (oauth shared / apikey per-account)
- ReplaceThinkingSignaturesInBody / ReplaceThinkingSignaturesInClaudeRequest
pure functions that cycle through pool entries for M>N replacements
- Harvester io.ReadCloser decorator for SSE + non-streaming JSON that
extracts content_block.signature fields best-effort into the pool
- 1h soft TTL constant for lazy expiry
New repository adapter internal/repository/signature_pool_cache.go
implements Redis ZSET storage with a single Lua script handling atomic
add + lazy expiry cleanup + capacity trim. Registered via
ProvideSignaturePool in the wire ProviderSet.
Settings extension: RectifierSettings gains a SignaturePoolSize int
field (0 = pool disabled / sticks with strip behavior; >0 = pool replace
is active). Threaded through service view, DTO, and handler GET/PUT
paths with bounds validation (max 1000).
ctxkey.IsSignatureRectifyRetry added so the harvester can later skip
ingesting signatures from retry requests we ourselves injected.
Introduce internal/service/signature package with ClaudeRectifier and
AntigravityRectifier interfaces plus StripClaudeRectifier /
StripAntigravityRectifier implementations that wrap the legacy
FilterThinkingBlocksForRetry / FilterSignatureSensitiveBlocksForRetry
and stripThinkingFromClaudeRequest / stripSignatureSensitiveBlocksFromClaudeRequest
functions. Refactor the Claude and Antigravity retry loops
(including count_tokens) to delegate body transformation to the
rectifier, keeping all surrounding scaffolding (ops events, logging,
time budget checks, HTTP plumbing) unchanged.
Also extracts the looksLikeToolSignatureError predicate previously
inlined as an anonymous closure.
Zero behavior change: all existing unit tests pass unchanged. This
prepares for Phase 2 where a Pool strategy will be plugged in via
the same interface.
Admin GET /api/v1/admin/payment/providers previously returned every
config value — including privateKey / apiV3Key / secretKey etc. —
verbatim. Any future XSS on the admin UI would hand attackers the
full set of production payment credentials, and the plaintext values
sat unnecessarily in browser memory for every operator.
Treat those fields as write-only from the admin surface:
- decryptAndMaskConfig() strips sensitive keys from the GET response.
The authoritative list is an explicit per-provider registry that
mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag:
alipay → privateKey, publicKey, alipayPublicKey
wxpay → privateKey, apiV3Key, publicKey
stripe → secretKey, webhookSecret (publishableKey stays plain)
easypay → pkey
Payment runtime still reads the full config via decryptConfig, so
nothing at the gateway changes.
- mergeConfig() treats an empty value for a sensitive key as "leave
unchanged" — the admin UI omits unchanged secrets so operators can
tweak non-sensitive settings without re-entering credentials.
- Admin dialog (PaymentProviderDialog.vue):
* secret inputs get autocomplete="new-password", data-1p-ignore,
data-lpignore and data-bwignore so password managers do not
offer to save provider credentials
* in edit mode the required-field check skips sensitive fields
(empty is the "keep existing" signal) and the placeholder shows
"leave empty to keep" instead of the default example value
* create mode still requires every non-optional field, including
secrets, since there is nothing to preserve
- Unit test renamed to TestIsSensitiveProviderConfigField, covers
the per-provider registry and specifically asserts that Stripe's
publishableKey is NOT treated as a secret.
The native Alipay provider previously tried to embed the payment page
URL into a QR code on the client — the URL is not a scannable payload
so the QR never worked. Merchants also hit a H5 detection mismatch
whenever the backend UA sniffer missed iPadOS 13+ or embedded browsers,
and the popup window was too small for Alipay's standard checkout
layout (QR + account-login panel on the right), forcing the user to
scroll horizontally and vertically.
Changes:
Backend
- alipay.go: drop QR-on-URL path. Use redirect-only flow —
alipay.trade.page.pay for PC (returns a gateway URL the browser
opens in a new window) and alipay.trade.wap.pay for H5 (returns a
URL the browser jumps to). Both flows produce pages on
openapi.alipaydev.com / excashier.alipay.com; the client never
renders a QR itself.
- payment_handler.go: add optional is_mobile bool to
CreateOrderRequest so the frontend can declare the device
explicitly. Server still falls back to UA sniffing when absent.
Frontend
- types/payment.ts, PaymentView.vue: declare is_mobile in
CreateOrderRequest and pass the computed isMobileDevice() value.
- providerConfig.ts: replace the two fixed POPUP_WINDOW_FEATURES
constants with getPaymentPopupFeatures(), which prefers 1250×900
(Alipay's checkout footprint), clamps to window.screen.avail* and
centers the popup so it never overflows on smaller laptops.
- PaymentQRDialog.vue, PaymentStatusPanel.vue, StripePaymentInline.vue,
PaymentView.vue: use the new helper at all popup call sites.
Alipay's page is ~1200x900; a fixed 1250x780 still clipped vertically.
Replace the constant with getPaymentPopupFeatures(): it prefers
1250x900, clamps to window.screen.avail*, and centers the popup so
it never overflows the visible work area on smaller laptops.
Drop POPUP_WINDOW_FEATURES and STRIPE_POPUP_WINDOW_FEATURES in favor of
the helper — all five call sites migrated.
Bump version to 0.1.114.9
Alipay's standard checkout (QR + account login panel) needs ~1200px
width. The default popup was 1000x750, forcing a horizontal scrollbar.
Unify to the wider size that the Stripe-alipay popup already used.
Bump version to 0.1.114.8
Previous pattern-based isSensitiveConfigField treated any key containing
"key" as a secret and stripped it from the admin response, which wrongly
hid Stripe's publishableKey (a public value). Edit dialogs then failed
its required-field validation because the backend no longer returned it.
Replace the pattern matcher with an explicit per-provider registry that
mirrors the frontend PROVIDER_CONFIG_FIELDS, so only true secrets are
masked/preserved:
- alipay: privateKey, publicKey, alipayPublicKey
- wxpay: privateKey, apiV3Key, publicKey
- stripe: secretKey, webhookSecret (publishableKey stays plaintext)
- easypay: pkey
Bump version to 0.1.114.7