feat(billing): B4 — 12h session window + paid tiers + 402 paywall + budget breaker#264
Merged
Conversation
…turn budget breaker PLAN_ACCOUNTS_BILLING_v1.0 milestone B4 (decisions locked 2026-07-21): - src/billing/quota.ts: the session-window engine. First standard-model turn opens a 12h window with a tier-dependent free allowance (Apple/verified email $5; unverified email $1; 30-day cumulative IAP >= $4.99 → $10, >= $19.99 → $20 — the tier perk decays 30d after purchases stop, the paid balance itself never expires per App Store 3.1.1). Debit order: free window first, then paid. Premium models (Claude/GPT) never touch the free window. Refund clawback can push paid negative: premium locks, the standard free window keeps working (decision #4). balance.json fast path beside the usage.jsonl audit ledger, mutated under the billing lock. - server.ts: /chat pre-checks the quota BEFORE the SSE handshake — exhaustion is a clean HTTP 402 with {error, resetAt, tier}; the granted budget flows into runAgent's budgetTokens breaker (tokensAffordable — every token priced conservatively at the output rate) so a single turn can't overshoot what the account can pay; the actual metered cost is debited after the turn. GET /api/billing/quota serves window/tier/balance to every client. - iOS: ChatView maps 402 to the paywall copy; AccountCard gains the session allowance bar (spent/left, reset countdown), tier and credits rows. Legacy shared-token demo stays operator-funded and ungated. Tests: window open/reset, tier windows, debit order, premium isolation, clawback semantics, exhaustion → 402 shape. Full suite 1051 pass / 0 fail; iOS simulator build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner
Author
🤖 Automated review — ✅ MERGE (nits)
Nits (non-blocking; #1 is a should-fix before real paying users):
|
oratis
added a commit
that referenced
this pull request
Jul 22, 2026
* fix(billing): 7 accounts/billing review follow-ups Hardening pass over the B0–B9 accounts/billing stack, from the review follow-ups on #260/#261/#264/#265/#266/#267/#272. Billing correctness — a metered turn can no longer ship free: - meter: recordUsage always returns the priced record; the audit-log append is best-effort and decoupled from pricing/debit, so ENOSPC on usage.jsonl loses the audit line, not the debit (#264). - limits: readCounter distinguishes ENOENT (0, fine) from an I/O or parse failure, and the $200/day global cap now fails CLOSED instead of silently reading as $0 spent (#267). - gateway: a 2xx upstream that reports no usage is priced from a bytes/4 estimate rather than debiting 0 (#264). Availability + abuse: - Bounded every unauthenticated body read behind readCappedText — readJsonBody, the Stripe webhook raw read (signature needs the raw string), and the gateway (LISA_GW_MAX_BODY_MB, default 20 MiB). Past the cap: 413 + Connection: close (#260/#264/#266). - accounts: scrypt moves off the event loop; /register gains a per-IP sliding-window cap (20 / 10 min). Coarse backstop, not a security boundary — XFF is client-controlled (#260). Correctness under multi-instance + replay: - turn-lease: heartbeat a held lease at TTL/3 so a chat SSE turn running under --timeout 3600 can't outlive its 180s lease and let a peer instance double-run the account (#272). - SIWA: verify Apple's nonce claim against sha256(raw nonce) end to end (server, web, iOS). LISA_CLOUD_APPLE_REQUIRE_NONCE is default-OFF so already-shipped TestFlight builds keep working (#261). - IAP: pin Apple Root CA G3 by SHA-256 on both the cached and fetched paths, assert basicConstraints CA on issuers, and assert Apple's marker OIDs on the leaf and the WWDR intermediate — a signature chain alone never said what each cert was FOR (#265). Verified: typecheck + build clean; 1104 tests pass (15 new). The iOS companion type-checks against the iPhoneSimulator SDK with SIWA both on and off — compile-verified, but the nonce path has not been exercised against a real Apple sign-in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(billing): rate-limit saturation must fail open; guard oidToDer DER length Two review follow-ups on the hardening pass itself. 1. `ipRateOk` refused every NEW key once the bucket map hit GENERIC_MAX_KEYS. The key is `auth:<clientIp>` and clientIp reads a spoofable x-forwarded-for, so one client could mint 10k distinct keys and lock every subsequent IP out of /register + /login — turning a DoS backstop into a DoS amplifier. It now fails OPEN on saturation, which is the correct direction for something its own docstring calls "NOT a security boundary": scrypt cost, email uniqueness and the per-email throttle are the real guards and none of them consults this map. 2. `oidToDer` emitted `content.length` as a bare short-form DER length. Past 127 bytes that is invalid DER, so the needle could never match and the cert role check would silently always fail. Now throws instead of lying. Also documents what `certHasOid` actually proves (TLV present anywhere in the cert, not extnID) so the comment no longer claims more than the check. Tests: saturation-fails-open (10k-key flood, new IP still admitted, existing key still capped) and long-form-OID refusal. Full suite 1106 pass / 0 fail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #263. Milestone B4 — the free-allowance / tier engine, per the locked decisions.
Engine (
src/billing/quota.ts)Server
/chatpre-checks before the SSE handshake → clean HTTP 402{error, resetAt, tier}; granted budget feedsrunAgent'sbudgetTokensbreaker (every token priced at the output rate) so one turn can't overshoot; actual metered cost debited post-turn.GET /api/billing/quota— window/tier/balance for all clients (same numbers everywhere).iOS
Tests
Window open/reset, tier math + 30d decay, debit order, premium isolation, clawback semantics. Full suite 1051 pass / 0 fail; iOS simulator build green.
🤖 Generated with Claude Code