Skip to content

feat(billing): B4 — 12h session window + paid tiers + 402 paywall + budget breaker#264

Merged
oratis merged 1 commit into
mainfrom
feat/quota-engine
Jul 22, 2026
Merged

feat(billing): B4 — 12h session window + paid tiers + 402 paywall + budget breaker#264
oratis merged 1 commit into
mainfrom
feat/quota-engine

Conversation

@oratis

@oratis oratis commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Stacked on #263. Milestone B4 — the free-allowance / tier engine, per the locked decisions.

Engine (src/billing/quota.ts)

  • First standard-model turn opens a 12h window with a tier-dependent allowance:
Tier Condition (30d cumulative IAP) Window
Free — (Apple / verified email) $5
Free-unverified — (email, pre-verification) $1
Tier 1 ≥ $4.99 $10
Tier 2 ≥ $19.99 $20
  • Tier perk decays 30 days after purchases stop; paid balance never expires (3.1.1).
  • Debit order: free window → paid balance. Premium models (Claude/GPT) never touch the free window — the one hard economics constraint from §7.
  • Refund clawback may push paid negative: premium locks, standard free window survives (decision fix(env): unquote reverses backslash escape #4).

Server

  • /chat pre-checks before the SSE handshake → clean HTTP 402 {error, resetAt, tier}; granted budget feeds runAgent's budgetTokens breaker (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).
  • Legacy shared-token demo stays ungated (operator-funded).

iOS

  • ChatView maps 402 → paywall copy; AccountCard gains the session allowance bar (spent/left + reset countdown), tier + credits rows.

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

…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>
@oratis

oratis commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

🤖 Automated review — ✅ MERGE (nits)

  • The 402 paywall is correctly fail-CLOSED: a thrown precheckTurn propagates before writeHead(200)/runTurn, so no inference is delivered on quota exhaustion. No normal-path bypass.

Nits (non-blocking; #1 is a should-fix before real paying users):

  • server.ts:2673-2681 — debit is gated on the usage.jsonl append succeeding (recordUsage returns null on ENOSPC/EDQUOT/EMFILE) → turn delivered but never debited = free, and since both the ledger and balance.json writes are lost the spend is unrecoverable. Price + debit independently of the audit write.
  • server.ts:2528-2540 + quota.ts:174-199 — precheck reserves nothing and hands the full remaining budget as the per-turn breaker; across instances, N concurrent requests at the boundary each run a full turn (overshoot ≈ N×breaker). Reserve an estimate at precheck, reconcile at debit.
  • quota.ts:226-233creditPurchase has no transactionId dedup on its own; resolved once feat(billing): B5 — StoreKit 2 consumable credits + server JWS verify + ASN refunds #265 lands (its global iap-transactions.json index dedupes before this call).

@oratis
oratis changed the base branch from feat/billing-meter to main July 22, 2026 09:02
@oratis
oratis merged commit cd471ba into main Jul 22, 2026
1 check passed
@oratis
oratis deleted the feat/quota-engine branch July 22, 2026 09:23
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant