Claude Review PR - Pay with AI3 feature review - Not intended to be merged - #638
Claude Review PR - Pay with AI3 feature review - Not intended to be merged#638EmilFattakhov wants to merge 114 commits into
Conversation
Introduces the data model foundation for Pay with AI3 economic protections.
Free/one-off allocation credits remain untouched on accounts.upload_limit /
accounts.download_limit. Purchased credits get their own table so the two
systems are completely independent — the upload path checks purchased credits
first and falls back to the existing allocation if none are available.
## What's added
**Migration 1 — purchased_credits table (20260302000000)**
- One row per purchase, each with an independent expires_at (never NULL).
- upload/download_bytes_original are immutable snapshots; _remaining columns
are decremented by the FIFO consumption logic.
- Index on (account_id, expired, expires_at) covers every active-credits query.
**Migration 2 — interactions.source column (20260302000001)**
- Adds source VARCHAR(32) CHECK IN ('free_tier', 'purchased') to interactions.
- Default 'free_tier' backfills all existing rows correctly.
- Required so the free-balance calculation (upload_limit - sum(interactions))
only counts free_tier rows and is not distorted by uploads charged against
purchased credits.
**@auto-drive/models — new types**
- PurchasedCreditSchema / PurchasedCredit — typed row from purchased_credits.
- PurchasedCreditSummary — aggregate returned by getRemainingCredits().
- InteractionSource enum (free_tier | purchased) — matches the new DB column.
**purchasedCredits repository**
- createPurchasedCredit() — called by onConfirmedIntent() in the next PR.
- getActiveByAccountId() — FIFO-ordered (soonest-expiry first).
- consumeCredits() — transactional FIFO deduction with FOR UPDATE row lock
to prevent concurrent uploads from double-spending. Returns neverthrow
InsufficientPurchasedCreditsError so the caller can fall back gracefully.
- getRemainingCredits() — single-query aggregate for cap enforcement.
- getExpiringCredits(withinDays) — for expiry warning banners.
- markExpiredCredits() — atomic CTE UPDATE for the expiry background job.
- getByAccountId() — full history including expired rows.
**config.ts — credits block**
- CREDIT_EXPIRY_DAYS (default 90)
- MAX_CREDITS_PER_USER (default 100 GiB as BigInt)
- CREDIT_EXPIRY_CHECK_INTERVAL (default 3600000 ms)
## What is NOT changed
- accounts table, upload_limit, download_limit — untouched.
- interactions table data and all existing queries — untouched (column add only).
- All existing use-cases and controllers — untouched.
- buyCredits feature flag and its middleware — untouched.
- TypeScript: zero errors across packages/models and apps/backend.
Co-Authored-By: Emil F <emil.e.fattakhov@gmail.com>
Sync prod with main
Sync production with main
…it-batches-schema Feat/pay with ai3/step 01 credit batches schema
…k-for-mismatched-metadata fix: gracefully handle zlib metadata mismatch on download
Sync production with main
The jwt callback was unconditionally calling refreshAccessToken() on every /api/auth/session request, causing a ~20s network round-trip to the auth service each time. Now it checks token.exp against the threshold first and returns the existing token if still valid. Also removes the redundant refreshAccessToken() call from the session callback (the jwt callback already handles refresh) and fixes a bug where the refreshed accessToken was immediately overwritten by the stale token.accessToken on the next line. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
With a 500ms TTL, nearly every call to getAuthSession() triggered a fresh /api/auth/session HTTP request. On a single page load, dozens of components and API calls invoke getAuthSession(), causing a storm of concurrent session requests (visible as 20+ parallel calls in the Network tab). Increasing to 30s collapses these into a single request that is reused across the page lifecycle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SessionEnsurer (in the parent drive layout) already calls AuthService.getMe() and sets the user in the Zustand store. UserProtectedLayout was independently calling getMe() again, which triggered an additional getAuthSession() round-trip plus an HTTP request to the auth service on every protected page load. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The bugbot correctly identified that token.exp is the NextAuth session JWT's expiry (reset to now + 7 days by NextAuth on every encode), not the auth service access token's expiry. This meant isNearExpiry was never true and refreshAccessToken() would never be called. Fix: store the access token's actual expiry as accessTokenExp in jwt.ts (both generateAccessToken and refreshAccessToken), and check that field in the jwt callback instead of the NextAuth-managed exp field. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
After sign-out or sign-in as a different user, API calls via getAuthSession() may use stale credentials for up to 30 seconds. The UI updates immediately since SessionProvider doesn't use this cache. Documents the trade-off and a future fix path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
If getSession() rejects or returns null (transient network error, logged-out state), the cached promise was retained for the full 30s TTL, blocking all API calls that depend on getAuthSession(). Now the cache is immediately invalidated on rejection or null/undefined results so the next caller retries fresh. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Expose invalidate() on memoizePromise so callers can manually bust the cache. Add clearSessionCache() to auth.ts and call it before signOut() in both ProfileDropdown and Profile views. This ensures API calls immediately after sign-out won't use the old session from the 30s memoization window. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
On sign-out, the Zustand user store (persisted to localStorage) was not cleared. When signing back in with a different wallet, the old user's data would flash until SessionEnsurer re-fetched. Found during testing of the session fixes. Extracts sign-out logic (clear session cache, clear user store, call nextAuth signOut) into a reusable useLogOut hook alongside useLogIn. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix: eliminate session request storm causing ~20s page loads
…logic (#601) * feat(pay-with-ai3): purchased_credits schema & repository (Phase 0) Introduces the data model foundation for Pay with AI3 economic protections. Free/one-off allocation credits remain untouched on accounts.upload_limit / accounts.download_limit. Purchased credits get their own table so the two systems are completely independent — the upload path checks purchased credits first and falls back to the existing allocation if none are available. ## What's added **Migration 1 — purchased_credits table (20260302000000)** - One row per purchase, each with an independent expires_at (never NULL). - upload/download_bytes_original are immutable snapshots; _remaining columns are decremented by the FIFO consumption logic. - Index on (account_id, expired, expires_at) covers every active-credits query. **Migration 2 — interactions.source column (20260302000001)** - Adds source VARCHAR(32) CHECK IN ('free_tier', 'purchased') to interactions. - Default 'free_tier' backfills all existing rows correctly. - Required so the free-balance calculation (upload_limit - sum(interactions)) only counts free_tier rows and is not distorted by uploads charged against purchased credits. **@auto-drive/models — new types** - PurchasedCreditSchema / PurchasedCredit — typed row from purchased_credits. - PurchasedCreditSummary — aggregate returned by getRemainingCredits(). - InteractionSource enum (free_tier | purchased) — matches the new DB column. **purchasedCredits repository** - createPurchasedCredit() — called by onConfirmedIntent() in the next PR. - getActiveByAccountId() — FIFO-ordered (soonest-expiry first). - consumeCredits() — transactional FIFO deduction with FOR UPDATE row lock to prevent concurrent uploads from double-spending. Returns neverthrow InsufficientPurchasedCreditsError so the caller can fall back gracefully. - getRemainingCredits() — single-query aggregate for cap enforcement. - getExpiringCredits(withinDays) — for expiry warning banners. - markExpiredCredits() — atomic CTE UPDATE for the expiry background job. - getByAccountId() — full history including expired rows. **config.ts — credits block** - CREDIT_EXPIRY_DAYS (default 90) - MAX_CREDITS_PER_USER (default 100 GiB as BigInt) - CREDIT_EXPIRY_CHECK_INTERVAL (default 3600000 ms) ## What is NOT changed - accounts table, upload_limit, download_limit — untouched. - interactions table data and all existing queries — untouched (column add only). - All existing use-cases and controllers — untouched. - buyCredits feature flag and its middleware — untouched. - TypeScript: zero errors across packages/models and apps/backend. Co-Authored-By: Emil F <emil.e.fattakhov@gmail.com> * feat(credits): wire purchased credits into core business logic Teach the upload/download pipeline to consume purchased credits first (FIFO across rows, soonest-expiry first) before falling back to the free/one-off allocation. ### Interactions layer - `interactionsRepository.createInteraction` now accepts a required `source: InteractionSource` ('free_tier' | 'purchased') and stores it in the new `interactions.source` column. - `getInteractionsByAccountIdAndTypeInTimeRange` accepts an optional `source` filter so callers can query just free-tier rows. - `InteractionsUseCases.createInteraction` forwards the source param. ### Accounts core - `getPendingCreditsByAccountAndType` now passes `InteractionSource.FreeTier` to the interactions query, so only free-tier uploads/downloads reduce the free limit. Purchased-credit interactions are invisible to this check. - `registerInteraction` (called on every upload/download) now: 1. Attempts to consume the full size from `purchased_credits` via `purchasedCreditsRepository.consumeCredits()` (FIFO, row-locked). 2. On success → one interaction row with source='purchased'. 3. On `InsufficientPurchasedCreditsError` → drains whatever purchased bytes remain (creates source='purchased' interaction), then records the remainder against the free allocation (source='free_tier'). This handles the "3×10 MB purchased, 29 MB upload" scenario transparently across as many purchased-credit rows as needed. - `addCreditsToAccount` is rewritten: - Enforces the per-user cap (`config.credits.maxBytesPerUser`, default 100 GiB) before inserting. - Creates a new `purchased_credits` row (one per purchase, with an independent `expiresAt` set to `config.credits.expiryDays` from now, default 90 days) instead of incrementing the flat account limits. - Signature change: `credits` is now `bigint` (was `number`); `intentId` is a new required third argument for the FK reference. ### Intents core - `getIntentCredits` updated to return `bigint` (was `number`) to match the new `addCreditsToAccount` signature. - `onConfirmedIntent` passes `intentId` as the third argument to `addCreditsToAccount`. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(credits): include purchased credits in gate check; update tests Two correctness fixes on top of the purchased-credits wiring: 1. **Upload/download gate** — `getPendingCreditsByAccountAndType` was returning only the free/one-off remaining bytes after the earlier change. This caused the guard in `handleFileUploadFinalization` and `sync.ts` (`pendingCredits < metadata.totalSize`) to reject uploads that were fully covered by purchased credits. Fixed by summing the free remaining bytes with the active purchased bytes returned by `purchasedCreditsRepository.getRemainingCredits`. When `buyCredits` is disabled (no purchased rows), `getRemainingCredits` returns 0, so one-off and monthly accounts are completely unaffected. 2. **Feature flag safety** — All new purchased-credit behaviour is driven by data in the `purchased_credits` table. That table can only be populated via confirmed on-chain intents, and the `/intents` routes are guarded by `featureFlagMiddleware('buyCredits')`. When the flag is OFF no rows can ever be created, so every code path in `registerInteraction`, `getPendingCreditsByAccountAndType`, and `addCreditsToAccount` falls through to the original free/one-off logic unchanged. 3. **Test suite** — Rewrote `credits.spec.ts` to match the new contracts: - Free-tier consumption tests remain (unchanged behaviour). - `addCreditsToAccount` test now verifies a `purchased_credits` row is created and that `accounts.upload_limit` is NOT mutated. - New test: purchased credits are reflected in `getPendingCreditsByUserAndType` (the gate now sees them). - New test: credits work for Monthly accounts (removed the old OneOff-only restriction, which no longer applies to on-chain purchases). - New test: cap enforcement rejects a purchase that would exceed 100 GiB. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(credits): fix unit and e2e tests broken by bigint + intentId changes Four categories of breakage fixed: 1. intents.spec.ts — getIntentCredits now returns bigint, so all assertions that compared to a plain number (100, 0) are updated to 100n / 0n. The two onConfirmedIntent spy expectations are updated to match the new addCreditsToAccount signature: (publicId, bigint_credits, intentId). 2. accounts.spec.ts (unit) — addCreditsToAccount describe block completely replaced: - Old "should error for non-OneOff" test removed (restriction no longer exists; on-chain purchases work for all account types). - Old "should add credits / updateAccount spy" test removed (accounts table is no longer touched; a purchased_credits row is created instead). - New tests mock purchasedCreditsRepository.getRemainingCredits and createPurchasedCredit, verify the correct row-creation call, and assert that accountsRepository.updateAccount is never called. - New test verifies Monthly accounts are accepted. - New test verifies cap rejection (getRemainingCredits returns cap). 3. credits.spec.ts (e2e) — purchased_credits.intent_id is a FK to intents(id). Tests were passing fake string IDs that don't exist, causing a FK constraint violation at insert time. Fixed by adding a createTestIntent() helper that inserts a minimal COMPLETED intent row and returns its id. All addCreditsToAccount call sites in the test file now use real intent IDs. 4. credits.spec.ts — same createTestIntent fix applied to the direct purchasedCreditsRepository.createPurchasedCredit call in the cap enforcement test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(credits): handle concurrent drain race + clarify feature-flag comment Two correctness fixes in registerInteraction and getPendingCreditsByAccountAndType: 1. **Concurrent drain race (critical)** The partial-drain path in registerInteraction called consumeCredits a second time with the `available` bytes reported by the first call, then unconditionally recorded a Purchased interaction and fell back to free tier for the remainder. The comment said this second call was "guaranteed to succeed" — but it is not. Between the two calls (each uses FOR UPDATE inside its own separate transaction) a concurrent request can consume the same bytes. If the second call fails, the old code would: - still record a Purchased interaction for bytes never debited - charge only (size - reported_available) against the free tier giving the user free credits and leaving the ledger inconsistent. Fix: check drainResult. On ok() set actuallyDrained = reportedAvailable and record the Purchased interaction. On err() log a warning and fall through with actuallyDrained = 0, charging the full size against the free tier. Either way the interaction records match the actual DB state. 2. **Misleading feature-flag comment (minor)** The old comment said "When buyCredits is disabled, getRemainingCredits returns 0". That is false — getRemainingCredits is a plain DB query with no flag awareness. Replaced with accurate documentation: - When the flag is OFF and no rows have ever been inserted, it returns 0. - If credits were purchased while the flag was ON and it is later disabled, those credits remain visible and usable. This is intentional: users should not lose credits they already paid for. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: replace two-phase consumeCredits with atomic consumeUpTo to eliminate TOCTOU race The previous registerInteraction implementation made two separate consumeCredits calls with a ROLLBACK (and lock release) in between. A concurrent request could acquire the same FOR UPDATE locks between the two calls, consuming credits that the second call assumed were still available. This led to either: - Recording a Purchased interaction for bytes never actually deducted - Charging the entire size to free tier despite available purchased credits Replace with a single consumeUpTo method that atomically drains as many purchased credit bytes as available (up to the requested amount) in one transaction, returning the actual amount consumed. The caller then splits the interaction into Purchased and FreeTier portions based on the definitive result — no stale snapshots, no lock gaps. * refactor(credits): remove dead consumeCredits code; fix vacuous test spy Dead code removal: - Delete InsufficientPurchasedCreditsError class — no longer thrown anywhere since registerInteraction switched to consumeUpTo - Delete consumeCredits function (~80 lines) — superseded by consumeUpTo, which performs the same FIFO deduction atomically without a lock gap - Remove now-unused neverthrow (err/ok/Result) import - Update consumeUpTo comment to remove stale references to consumeCredits Test fix (accounts.spec.ts): - Move accountsRepository.updateAccount spy to BEFORE the addCreditsToAccount call so it actually intercepts any invocation during execution. The previous placement (after the call) made the not.toHaveBeenCalled() assertion trivially true regardless of production behaviour. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(credits): eliminate two concurrency bugs in credit accounting Issue 1 — non-atomic credit consumption (medium severity) consumeUpTo commits its own DB transaction before createInteraction records the ledger entry. If the INSERT fails after the deduction commits, purchased credits are permanently lost with no interaction record. Fix: wrap the purchased-side createInteraction call in a try-catch that attempts a compensating refundCredits transaction (new repository method) to restore the deducted bytes to the earliest-expiring active rows. If the refund also fails, a CRITICAL log is emitted with enough context for manual recovery. The error is always re-thrown so the caller knows the operation failed. The free-tier path is unaffected (no prior state mutation). Issue 2 — TOCTOU race in cap enforcement (medium severity) addCreditsToAccount read getRemainingCredits then called createPurchasedCredit as two separate operations. Two concurrent onConfirmedIntent calls for different intents on the same account could both pass the cap check and both insert, pushing the account over the per-user cap. Fix: replace the two separate calls with createPurchasedCreditWithCapCheck (new repository method) that acquires a per-account PostgreSQL advisory lock (pg_advisory_xact_lock keyed on hashtext(accountId)), re-reads the remaining credits inside the locked transaction, then inserts or aborts atomically. The lock is automatically released on transaction end. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(lint): remove unused originalCol variable in refundCredits originalCol was declared but the function reads original byte values directly from the row object, so the variable was never referenced. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(tests): update addCreditsToAccount mocks to use createPurchasedCreditWithCapCheck The three unit tests for addCreditsToAccount were mocking the old two-step getRemainingCredits + createPurchasedCredit API, but the use case was refactored to use the atomic createPurchasedCreditWithCapCheck which handles cap enforcement and insertion in a single DB transaction. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(e2e): add double-spend protection test for concurrent purchased credit uploads Adds an e2e test that fires two concurrent 90 MB registerInteraction calls against an account with exactly 100 MB of purchased credits and zero free-tier allocation. Verifies three properties of the atomic consumeUpTo implementation: - purchased_credits.uploadBytesRemaining reaches exactly 0 (not negative) - total bytes recorded as InteractionSource.Purchased = 100 MB (not 180 MB, which would indicate both calls each consumed 90 MB independently) - getPendingCreditsByUserAndType returns < 90 MB, so a third upload would be denied at the gate Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(credits): reject registerInteraction when free-tier budget is insufficient after partial consumeUpTo Previously, if two concurrent uploads raced past the gate check, the second caller would receive only a fraction of its requested bytes from the purchased pool (whatever remained after the first caller's FOR UPDATE lock drained it) and silently overflow the rest into free-tier credits it does not own — a double-spend. The fix adds a last-line-of-defence guard inside registerInteraction: after consumeUpTo returns, if the uncovered remainder (fromFree) exceeds the account's available free-tier budget, the function refunds the partial purchased-credit deduction via refundCredits and throws PaymentRequiredError. The double-spend e2e test is updated accordingly: - Promise.allSettled replaces Promise.all so the rejection is observable - Asserts exactly one fulfilled / one rejected result - Asserts 10 MB purchased remaining (the 10 MB consumed then refunded) - Asserts only 90 MB in the Purchased interaction ledger (not 100 MB) - Asserts pendingAfter === 10 MB (the only unspent credits left) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(credits): move free-tier budget guard before interaction ledger writes The guard that detects insufficient free-tier credits was placed after the purchased interaction record had already been written to the DB. When the guard fired, it refunded the credit bytes via refundCredits but left a dangling Purchased ledger entry — causing totalPurchasedConsumed to be 100 MB instead of 90 MB in the double-spend test. Fix: reorder so the free-tier budget check runs before any interaction is written. If the guard rejects the request, refundCredits restores the purchased bytes and no ledger entries exist at all, leaving the system in a fully clean state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(tests): resolve mockResolvedValue type error in accounts.spec.ts Jest's mockResolvedValue() requires an explicit argument when the mocked function has a non-void return type. accountsRepository. updateAccount returns Promise<Account>, so calling mockResolvedValue() with no argument causes a type error under stricter ts-jest configs. Pass `undefined as any` to silence the error while keeping the test intent (asserting the spy is NOT called). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(credits): use dynamic creditType in PaymentRequiredError message The error was hardcoded to say "upload" even for download interactions. Made-with: Cursor * Fix line duplication in apps/backend/__tests__/unit/useCases/accounts.spec.ts --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(pay-with-ai3): purchased_credits schema & repository (Phase 0)
Introduces the data model foundation for Pay with AI3 economic protections.
Free/one-off allocation credits remain untouched on accounts.upload_limit /
accounts.download_limit. Purchased credits get their own table so the two
systems are completely independent — the upload path checks purchased credits
first and falls back to the existing allocation if none are available.
## What's added
**Migration 1 — purchased_credits table (20260302000000)**
- One row per purchase, each with an independent expires_at (never NULL).
- upload/download_bytes_original are immutable snapshots; _remaining columns
are decremented by the FIFO consumption logic.
- Index on (account_id, expired, expires_at) covers every active-credits query.
**Migration 2 — interactions.source column (20260302000001)**
- Adds source VARCHAR(32) CHECK IN ('free_tier', 'purchased') to interactions.
- Default 'free_tier' backfills all existing rows correctly.
- Required so the free-balance calculation (upload_limit - sum(interactions))
only counts free_tier rows and is not distorted by uploads charged against
purchased credits.
**@auto-drive/models — new types**
- PurchasedCreditSchema / PurchasedCredit — typed row from purchased_credits.
- PurchasedCreditSummary — aggregate returned by getRemainingCredits().
- InteractionSource enum (free_tier | purchased) — matches the new DB column.
**purchasedCredits repository**
- createPurchasedCredit() — called by onConfirmedIntent() in the next PR.
- getActiveByAccountId() — FIFO-ordered (soonest-expiry first).
- consumeCredits() — transactional FIFO deduction with FOR UPDATE row lock
to prevent concurrent uploads from double-spending. Returns neverthrow
InsufficientPurchasedCreditsError so the caller can fall back gracefully.
- getRemainingCredits() — single-query aggregate for cap enforcement.
- getExpiringCredits(withinDays) — for expiry warning banners.
- markExpiredCredits() — atomic CTE UPDATE for the expiry background job.
- getByAccountId() — full history including expired rows.
**config.ts — credits block**
- CREDIT_EXPIRY_DAYS (default 90)
- MAX_CREDITS_PER_USER (default 100 GiB as BigInt)
- CREDIT_EXPIRY_CHECK_INTERVAL (default 3600000 ms)
## What is NOT changed
- accounts table, upload_limit, download_limit — untouched.
- interactions table data and all existing queries — untouched (column add only).
- All existing use-cases and controllers — untouched.
- buyCredits feature flag and its middleware — untouched.
- TypeScript: zero errors across packages/models and apps/backend.
Co-Authored-By: Emil F <emil.e.fattakhov@gmail.com>
* feat(credits): wire purchased credits into core business logic
Teach the upload/download pipeline to consume purchased credits first
(FIFO across rows, soonest-expiry first) before falling back to the
free/one-off allocation.
### Interactions layer
- `interactionsRepository.createInteraction` now accepts a required
`source: InteractionSource` ('free_tier' | 'purchased') and stores
it in the new `interactions.source` column.
- `getInteractionsByAccountIdAndTypeInTimeRange` accepts an optional
`source` filter so callers can query just free-tier rows.
- `InteractionsUseCases.createInteraction` forwards the source param.
### Accounts core
- `getPendingCreditsByAccountAndType` now passes
`InteractionSource.FreeTier` to the interactions query, so only
free-tier uploads/downloads reduce the free limit. Purchased-credit
interactions are invisible to this check.
- `registerInteraction` (called on every upload/download) now:
1. Attempts to consume the full size from `purchased_credits` via
`purchasedCreditsRepository.consumeCredits()` (FIFO, row-locked).
2. On success → one interaction row with source='purchased'.
3. On `InsufficientPurchasedCreditsError` → drains whatever
purchased bytes remain (creates source='purchased' interaction),
then records the remainder against the free allocation
(source='free_tier').
This handles the "3×10 MB purchased, 29 MB upload" scenario
transparently across as many purchased-credit rows as needed.
- `addCreditsToAccount` is rewritten:
- Enforces the per-user cap (`config.credits.maxBytesPerUser`,
default 100 GiB) before inserting.
- Creates a new `purchased_credits` row (one per purchase, with an
independent `expiresAt` set to `config.credits.expiryDays` from
now, default 90 days) instead of incrementing the flat account
limits.
- Signature change: `credits` is now `bigint` (was `number`);
`intentId` is a new required third argument for the FK reference.
### Intents core
- `getIntentCredits` updated to return `bigint` (was `number`) to
match the new `addCreditsToAccount` signature.
- `onConfirmedIntent` passes `intentId` as the third argument to
`addCreditsToAccount`.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(credits): include purchased credits in gate check; update tests
Two correctness fixes on top of the purchased-credits wiring:
1. **Upload/download gate** — `getPendingCreditsByAccountAndType` was
returning only the free/one-off remaining bytes after the earlier
change. This caused the guard in `handleFileUploadFinalization` and
`sync.ts` (`pendingCredits < metadata.totalSize`) to reject uploads
that were fully covered by purchased credits. Fixed by summing the
free remaining bytes with the active purchased bytes returned by
`purchasedCreditsRepository.getRemainingCredits`. When `buyCredits`
is disabled (no purchased rows), `getRemainingCredits` returns 0, so
one-off and monthly accounts are completely unaffected.
2. **Feature flag safety** — All new purchased-credit behaviour is
driven by data in the `purchased_credits` table. That table can only
be populated via confirmed on-chain intents, and the `/intents`
routes are guarded by `featureFlagMiddleware('buyCredits')`. When the
flag is OFF no rows can ever be created, so every code path in
`registerInteraction`, `getPendingCreditsByAccountAndType`, and
`addCreditsToAccount` falls through to the original free/one-off
logic unchanged.
3. **Test suite** — Rewrote `credits.spec.ts` to match the new
contracts:
- Free-tier consumption tests remain (unchanged behaviour).
- `addCreditsToAccount` test now verifies a `purchased_credits` row
is created and that `accounts.upload_limit` is NOT mutated.
- New test: purchased credits are reflected in
`getPendingCreditsByUserAndType` (the gate now sees them).
- New test: credits work for Monthly accounts (removed the old
OneOff-only restriction, which no longer applies to on-chain
purchases).
- New test: cap enforcement rejects a purchase that would exceed
100 GiB.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(credits): fix unit and e2e tests broken by bigint + intentId changes
Four categories of breakage fixed:
1. intents.spec.ts — getIntentCredits now returns bigint, so all
assertions that compared to a plain number (100, 0) are updated to
100n / 0n. The two onConfirmedIntent spy expectations are updated to
match the new addCreditsToAccount signature:
(publicId, bigint_credits, intentId).
2. accounts.spec.ts (unit) — addCreditsToAccount describe block
completely replaced:
- Old "should error for non-OneOff" test removed (restriction
no longer exists; on-chain purchases work for all account types).
- Old "should add credits / updateAccount spy" test removed
(accounts table is no longer touched; a purchased_credits row is
created instead).
- New tests mock purchasedCreditsRepository.getRemainingCredits and
createPurchasedCredit, verify the correct row-creation call, and
assert that accountsRepository.updateAccount is never called.
- New test verifies Monthly accounts are accepted.
- New test verifies cap rejection (getRemainingCredits returns cap).
3. credits.spec.ts (e2e) — purchased_credits.intent_id is a FK to
intents(id). Tests were passing fake string IDs that don't exist,
causing a FK constraint violation at insert time. Fixed by adding a
createTestIntent() helper that inserts a minimal COMPLETED intent row
and returns its id. All addCreditsToAccount call sites in the test
file now use real intent IDs.
4. credits.spec.ts — same createTestIntent fix applied to the direct
purchasedCreditsRepository.createPurchasedCredit call in the cap
enforcement test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(credits): handle concurrent drain race + clarify feature-flag comment
Two correctness fixes in registerInteraction and getPendingCreditsByAccountAndType:
1. **Concurrent drain race (critical)**
The partial-drain path in registerInteraction called consumeCredits
a second time with the `available` bytes reported by the first call,
then unconditionally recorded a Purchased interaction and fell back
to free tier for the remainder. The comment said this second call was
"guaranteed to succeed" — but it is not.
Between the two calls (each uses FOR UPDATE inside its own separate
transaction) a concurrent request can consume the same bytes. If the
second call fails, the old code would:
- still record a Purchased interaction for bytes never debited
- charge only (size - reported_available) against the free tier
giving the user free credits and leaving the ledger inconsistent.
Fix: check drainResult. On ok() set actuallyDrained = reportedAvailable
and record the Purchased interaction. On err() log a warning and fall
through with actuallyDrained = 0, charging the full size against the
free tier. Either way the interaction records match the actual DB state.
2. **Misleading feature-flag comment (minor)**
The old comment said "When buyCredits is disabled, getRemainingCredits
returns 0". That is false — getRemainingCredits is a plain DB query
with no flag awareness. Replaced with accurate documentation:
- When the flag is OFF and no rows have ever been inserted, it returns 0.
- If credits were purchased while the flag was ON and it is later
disabled, those credits remain visible and usable. This is intentional:
users should not lose credits they already paid for.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(pay-with-ai3): intent expiry & price-lock protection (Phase 1, Task 1.2)
Adds a configurable price-lock window to intents so stale PENDING intents
cannot be confirmed after the market price has moved. Also unifies the
purchased-credit expiry background job (previously wired ad-hoc) into a
single CreditExpiryJob service that handles both credit row expiry and
intent cleanup in one place.
Changes:
- migration: add expires_at column + partial index on intents table
- models: add EXPIRED to IntentStatus enum; add optional expiresAt to Intent
- config: add credits.intentExpiryMinutes (default 10, env INTENT_EXPIRY_MINUTES)
- errors: add GoneError (HTTP 410) for expired-intent responses
- intentsRepository: persist/hydrate expires_at; add getExpiredPendingIntents()
- IntentsUseCases:
- createIntent sets expiresAt = now + intentExpiryMinutes
- getIntent / triggerWatchIntent reject expired intents with GoneError
- legacy intents without expiresAt are never treated as expired
- add cleanupExpiredIntents() to mark stale PENDING rows as EXPIRED
- creditExpiryJob: new service that runs markExpiredCredits() +
cleanupExpiredIntents() on config.credits.expiryCheckIntervalMs interval
- frontendWorker: start/stop creditExpiryJob alongside paymentManager when
buyCredits flag is active
- tests: expand intents.spec.ts with expiry, GoneError, legacy-row, and
cleanupExpiredIntents coverage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: replace two-phase consumeCredits with atomic consumeUpTo to eliminate TOCTOU race
The previous registerInteraction implementation made two separate
consumeCredits calls with a ROLLBACK (and lock release) in between.
A concurrent request could acquire the same FOR UPDATE locks between
the two calls, consuming credits that the second call assumed were
still available. This led to either:
- Recording a Purchased interaction for bytes never actually deducted
- Charging the entire size to free tier despite available purchased credits
Replace with a single consumeUpTo method that atomically drains as
many purchased credit bytes as available (up to the requested amount)
in one transaction, returning the actual amount consumed. The caller
then splits the interaction into Purchased and FreeTier portions
based on the definitive result — no stale snapshots, no lock gaps.
* refactor(credits): remove dead consumeCredits code; fix vacuous test spy
Dead code removal:
- Delete InsufficientPurchasedCreditsError class — no longer thrown anywhere
since registerInteraction switched to consumeUpTo
- Delete consumeCredits function (~80 lines) — superseded by consumeUpTo,
which performs the same FIFO deduction atomically without a lock gap
- Remove now-unused neverthrow (err/ok/Result) import
- Update consumeUpTo comment to remove stale references to consumeCredits
Test fix (accounts.spec.ts):
- Move accountsRepository.updateAccount spy to BEFORE the addCreditsToAccount
call so it actually intercepts any invocation during execution. The previous
placement (after the call) made the not.toHaveBeenCalled() assertion
trivially true regardless of production behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(credits): eliminate two concurrency bugs in credit accounting
Issue 1 — non-atomic credit consumption (medium severity)
consumeUpTo commits its own DB transaction before createInteraction
records the ledger entry. If the INSERT fails after the deduction commits,
purchased credits are permanently lost with no interaction record.
Fix: wrap the purchased-side createInteraction call in a try-catch that
attempts a compensating refundCredits transaction (new repository method)
to restore the deducted bytes to the earliest-expiring active rows. If the
refund also fails, a CRITICAL log is emitted with enough context for manual
recovery. The error is always re-thrown so the caller knows the operation
failed. The free-tier path is unaffected (no prior state mutation).
Issue 2 — TOCTOU race in cap enforcement (medium severity)
addCreditsToAccount read getRemainingCredits then called createPurchasedCredit
as two separate operations. Two concurrent onConfirmedIntent calls for
different intents on the same account could both pass the cap check and
both insert, pushing the account over the per-user cap.
Fix: replace the two separate calls with createPurchasedCreditWithCapCheck
(new repository method) that acquires a per-account PostgreSQL advisory lock
(pg_advisory_xact_lock keyed on hashtext(accountId)), re-reads the remaining
credits inside the locked transaction, then inserts or aborts atomically.
The lock is automatically released on transaction end.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(lint): remove unused originalCol variable in refundCredits
originalCol was declared but the function reads original byte values
directly from the row object, so the variable was never referenced.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(intents): prevent TOCTOU race in cleanupExpiredIntents
cleanupExpiredIntents used a read-then-write pattern that could overwrite
a CONFIRMED status (and paymentAmount) with stale EXPIRED/null data if
markIntentAsConfirmed ran concurrently.
- Add expireIntentIfPending: atomic conditional UPDATE (WHERE status = 'pending')
that no-ops if the status already changed
- Filter getExpiredPendingIntents with AND tx_hash IS NULL so intents with
active on-chain transactions are never candidates for expiry cleanup
- Update cleanupExpiredIntents to use the atomic method and log skipped intents
Made-with: Cursor
* fix(credits): isolate error handling in runExpiryCheck to prevent coupled failures
markExpiredCredits() and cleanupExpiredIntents() are independent operations
that were called sequentially with no individual error handling. A DB failure
in credit expiry would prevent intent expiry from ever running. Wrap each in
its own try/catch so one failing does not block the other.
Made-with: Cursor
* fix(tests): update addCreditsToAccount mocks to use createPurchasedCreditWithCapCheck
The three unit tests for addCreditsToAccount were mocking the old
two-step getRemainingCredits + createPurchasedCredit API, but the
use case was refactored to use the atomic createPurchasedCreditWithCapCheck
which handles cap enforcement and insertion in a single DB transaction.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(e2e): add double-spend protection test for concurrent purchased credit uploads
Adds an e2e test that fires two concurrent 90 MB registerInteraction
calls against an account with exactly 100 MB of purchased credits and
zero free-tier allocation.
Verifies three properties of the atomic consumeUpTo implementation:
- purchased_credits.uploadBytesRemaining reaches exactly 0 (not negative)
- total bytes recorded as InteractionSource.Purchased = 100 MB (not 180 MB,
which would indicate both calls each consumed 90 MB independently)
- getPendingCreditsByUserAndType returns < 90 MB, so a third upload would
be denied at the gate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(credits): reject registerInteraction when free-tier budget is insufficient after partial consumeUpTo
Previously, if two concurrent uploads raced past the gate check, the
second caller would receive only a fraction of its requested bytes from
the purchased pool (whatever remained after the first caller's FOR UPDATE
lock drained it) and silently overflow the rest into free-tier credits it
does not own — a double-spend.
The fix adds a last-line-of-defence guard inside registerInteraction:
after consumeUpTo returns, if the uncovered remainder (fromFree) exceeds
the account's available free-tier budget, the function refunds the partial
purchased-credit deduction via refundCredits and throws PaymentRequiredError.
The double-spend e2e test is updated accordingly:
- Promise.allSettled replaces Promise.all so the rejection is observable
- Asserts exactly one fulfilled / one rejected result
- Asserts 10 MB purchased remaining (the 10 MB consumed then refunded)
- Asserts only 90 MB in the Purchased interaction ledger (not 100 MB)
- Asserts pendingAfter === 10 MB (the only unspent credits left)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(credits): move free-tier budget guard before interaction ledger writes
The guard that detects insufficient free-tier credits was placed after
the purchased interaction record had already been written to the DB.
When the guard fired, it refunded the credit bytes via refundCredits but
left a dangling Purchased ledger entry — causing totalPurchasedConsumed
to be 100 MB instead of 90 MB in the double-spend test.
Fix: reorder so the free-tier budget check runs before any interaction is
written. If the guard rejects the request, refundCredits restores the
purchased bytes and no ledger entries exist at all, leaving the system in
a fully clean state.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(tests): resolve mockResolvedValue type error in accounts.spec.ts
Jest's mockResolvedValue() requires an explicit argument when the
mocked function has a non-void return type. accountsRepository.
updateAccount returns Promise<Account>, so calling mockResolvedValue()
with no argument causes a type error under stricter ts-jest configs.
Pass `undefined as any` to silence the error while keeping the test
intent (asserting the spy is NOT called).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(credits): use dynamic creditType in PaymentRequiredError message
The error was hardcoded to say "upload" even for download interactions.
Made-with: Cursor
* Fix line duplication in apps/backend/__tests__/unit/useCases/accounts.spec.ts
* fix(intents): scope expiry check to PENDING intents only
isIntentExpired was rejecting CONFIRMED/COMPLETED intents whose
expiresAt had passed, returning GoneError on GET /:id. The expiry
window only applies to PENDING intents — once confirmed or completed
the price-lock window is irrelevant.
Made-with: Cursor
* fix(intents): expire pre-feature rows and enforce NOT NULL on expires_at
Intents without an expiresAt were previously treated as never expired.
Since the feature is not yet public it is safe to expire all existing
PENDING intents that have no tx_hash, then make the column NOT NULL so
every future intent must carry an explicit expiry.
- Migration: expire NULL/PENDING/no-tx_hash rows, backfill sentinel for
remaining NULLs, then ALTER COLUMN expires_at SET NOT NULL
- isIntentExpired: return true when expiresAt is absent (was false)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(intents): recognize EXPIRED status in isIntentExpired
isIntentExpired returned false for intents with IntentStatus.EXPIRED
because the early-return `status !== PENDING` fired first. After
cleanupExpiredIntents changed a PENDING intent to EXPIRED,
getIntent would return ok(intent) instead of GoneError, allowing
triggerWatchIntent to bypass the price-lock window.
Made-with: Cursor
* fix(tests): include expires_at in createTestIntent helper
The intent-expiry migration made expires_at NOT NULL. The e2e test
helper was inserting intents without it, causing all 5 credits e2e
tests to fail on CI.
Made-with: Cursor
* fix(intents): skip expiry check for PENDING intents with a txHash
isIntentExpired was treating all PENDING intents past their expiresAt
as expired, even when a txHash was present (meaning the transaction is
actively being watched on-chain). This caused getIntent to return a
410 GoneError while the frontend was polling for confirmation, leaving
users in a stuck state. Aligns the API-level check with the background
cleanup query which already guards with AND tx_hash IS NULL.
Made-with: Cursor
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…blic launch … (#603) * feat(pay-with-ai3): purchased_credits schema & repository (Phase 0) Introduces the data model foundation for Pay with AI3 economic protections. Free/one-off allocation credits remain untouched on accounts.upload_limit / accounts.download_limit. Purchased credits get their own table so the two systems are completely independent — the upload path checks purchased credits first and falls back to the existing allocation if none are available. ## What's added **Migration 1 — purchased_credits table (20260302000000)** - One row per purchase, each with an independent expires_at (never NULL). - upload/download_bytes_original are immutable snapshots; _remaining columns are decremented by the FIFO consumption logic. - Index on (account_id, expired, expires_at) covers every active-credits query. **Migration 2 — interactions.source column (20260302000001)** - Adds source VARCHAR(32) CHECK IN ('free_tier', 'purchased') to interactions. - Default 'free_tier' backfills all existing rows correctly. - Required so the free-balance calculation (upload_limit - sum(interactions)) only counts free_tier rows and is not distorted by uploads charged against purchased credits. **@auto-drive/models — new types** - PurchasedCreditSchema / PurchasedCredit — typed row from purchased_credits. - PurchasedCreditSummary — aggregate returned by getRemainingCredits(). - InteractionSource enum (free_tier | purchased) — matches the new DB column. **purchasedCredits repository** - createPurchasedCredit() — called by onConfirmedIntent() in the next PR. - getActiveByAccountId() — FIFO-ordered (soonest-expiry first). - consumeCredits() — transactional FIFO deduction with FOR UPDATE row lock to prevent concurrent uploads from double-spending. Returns neverthrow InsufficientPurchasedCreditsError so the caller can fall back gracefully. - getRemainingCredits() — single-query aggregate for cap enforcement. - getExpiringCredits(withinDays) — for expiry warning banners. - markExpiredCredits() — atomic CTE UPDATE for the expiry background job. - getByAccountId() — full history including expired rows. **config.ts — credits block** - CREDIT_EXPIRY_DAYS (default 90) - MAX_CREDITS_PER_USER (default 100 GiB as BigInt) - CREDIT_EXPIRY_CHECK_INTERVAL (default 3600000 ms) ## What is NOT changed - accounts table, upload_limit, download_limit — untouched. - interactions table data and all existing queries — untouched (column add only). - All existing use-cases and controllers — untouched. - buyCredits feature flag and its middleware — untouched. - TypeScript: zero errors across packages/models and apps/backend. Co-Authored-By: Emil F <emil.e.fattakhov@gmail.com> * feat(credits): wire purchased credits into core business logic Teach the upload/download pipeline to consume purchased credits first (FIFO across rows, soonest-expiry first) before falling back to the free/one-off allocation. ### Interactions layer - `interactionsRepository.createInteraction` now accepts a required `source: InteractionSource` ('free_tier' | 'purchased') and stores it in the new `interactions.source` column. - `getInteractionsByAccountIdAndTypeInTimeRange` accepts an optional `source` filter so callers can query just free-tier rows. - `InteractionsUseCases.createInteraction` forwards the source param. ### Accounts core - `getPendingCreditsByAccountAndType` now passes `InteractionSource.FreeTier` to the interactions query, so only free-tier uploads/downloads reduce the free limit. Purchased-credit interactions are invisible to this check. - `registerInteraction` (called on every upload/download) now: 1. Attempts to consume the full size from `purchased_credits` via `purchasedCreditsRepository.consumeCredits()` (FIFO, row-locked). 2. On success → one interaction row with source='purchased'. 3. On `InsufficientPurchasedCreditsError` → drains whatever purchased bytes remain (creates source='purchased' interaction), then records the remainder against the free allocation (source='free_tier'). This handles the "3×10 MB purchased, 29 MB upload" scenario transparently across as many purchased-credit rows as needed. - `addCreditsToAccount` is rewritten: - Enforces the per-user cap (`config.credits.maxBytesPerUser`, default 100 GiB) before inserting. - Creates a new `purchased_credits` row (one per purchase, with an independent `expiresAt` set to `config.credits.expiryDays` from now, default 90 days) instead of incrementing the flat account limits. - Signature change: `credits` is now `bigint` (was `number`); `intentId` is a new required third argument for the FK reference. ### Intents core - `getIntentCredits` updated to return `bigint` (was `number`) to match the new `addCreditsToAccount` signature. - `onConfirmedIntent` passes `intentId` as the third argument to `addCreditsToAccount`. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(credits): include purchased credits in gate check; update tests Two correctness fixes on top of the purchased-credits wiring: 1. **Upload/download gate** — `getPendingCreditsByAccountAndType` was returning only the free/one-off remaining bytes after the earlier change. This caused the guard in `handleFileUploadFinalization` and `sync.ts` (`pendingCredits < metadata.totalSize`) to reject uploads that were fully covered by purchased credits. Fixed by summing the free remaining bytes with the active purchased bytes returned by `purchasedCreditsRepository.getRemainingCredits`. When `buyCredits` is disabled (no purchased rows), `getRemainingCredits` returns 0, so one-off and monthly accounts are completely unaffected. 2. **Feature flag safety** — All new purchased-credit behaviour is driven by data in the `purchased_credits` table. That table can only be populated via confirmed on-chain intents, and the `/intents` routes are guarded by `featureFlagMiddleware('buyCredits')`. When the flag is OFF no rows can ever be created, so every code path in `registerInteraction`, `getPendingCreditsByAccountAndType`, and `addCreditsToAccount` falls through to the original free/one-off logic unchanged. 3. **Test suite** — Rewrote `credits.spec.ts` to match the new contracts: - Free-tier consumption tests remain (unchanged behaviour). - `addCreditsToAccount` test now verifies a `purchased_credits` row is created and that `accounts.upload_limit` is NOT mutated. - New test: purchased credits are reflected in `getPendingCreditsByUserAndType` (the gate now sees them). - New test: credits work for Monthly accounts (removed the old OneOff-only restriction, which no longer applies to on-chain purchases). - New test: cap enforcement rejects a purchase that would exceed 100 GiB. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(credits): fix unit and e2e tests broken by bigint + intentId changes Four categories of breakage fixed: 1. intents.spec.ts — getIntentCredits now returns bigint, so all assertions that compared to a plain number (100, 0) are updated to 100n / 0n. The two onConfirmedIntent spy expectations are updated to match the new addCreditsToAccount signature: (publicId, bigint_credits, intentId). 2. accounts.spec.ts (unit) — addCreditsToAccount describe block completely replaced: - Old "should error for non-OneOff" test removed (restriction no longer exists; on-chain purchases work for all account types). - Old "should add credits / updateAccount spy" test removed (accounts table is no longer touched; a purchased_credits row is created instead). - New tests mock purchasedCreditsRepository.getRemainingCredits and createPurchasedCredit, verify the correct row-creation call, and assert that accountsRepository.updateAccount is never called. - New test verifies Monthly accounts are accepted. - New test verifies cap rejection (getRemainingCredits returns cap). 3. credits.spec.ts (e2e) — purchased_credits.intent_id is a FK to intents(id). Tests were passing fake string IDs that don't exist, causing a FK constraint violation at insert time. Fixed by adding a createTestIntent() helper that inserts a minimal COMPLETED intent row and returns its id. All addCreditsToAccount call sites in the test file now use real intent IDs. 4. credits.spec.ts — same createTestIntent fix applied to the direct purchasedCreditsRepository.createPurchasedCredit call in the cap enforcement test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(credits): handle concurrent drain race + clarify feature-flag comment Two correctness fixes in registerInteraction and getPendingCreditsByAccountAndType: 1. **Concurrent drain race (critical)** The partial-drain path in registerInteraction called consumeCredits a second time with the `available` bytes reported by the first call, then unconditionally recorded a Purchased interaction and fell back to free tier for the remainder. The comment said this second call was "guaranteed to succeed" — but it is not. Between the two calls (each uses FOR UPDATE inside its own separate transaction) a concurrent request can consume the same bytes. If the second call fails, the old code would: - still record a Purchased interaction for bytes never debited - charge only (size - reported_available) against the free tier giving the user free credits and leaving the ledger inconsistent. Fix: check drainResult. On ok() set actuallyDrained = reportedAvailable and record the Purchased interaction. On err() log a warning and fall through with actuallyDrained = 0, charging the full size against the free tier. Either way the interaction records match the actual DB state. 2. **Misleading feature-flag comment (minor)** The old comment said "When buyCredits is disabled, getRemainingCredits returns 0". That is false — getRemainingCredits is a plain DB query with no flag awareness. Replaced with accurate documentation: - When the flag is OFF and no rows have ever been inserted, it returns 0. - If credits were purchased while the flag was ON and it is later disabled, those credits remain visible and usable. This is intentional: users should not lose credits they already paid for. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(pay-with-ai3): intent expiry & price-lock protection (Phase 1, Task 1.2) Adds a configurable price-lock window to intents so stale PENDING intents cannot be confirmed after the market price has moved. Also unifies the purchased-credit expiry background job (previously wired ad-hoc) into a single CreditExpiryJob service that handles both credit row expiry and intent cleanup in one place. Changes: - migration: add expires_at column + partial index on intents table - models: add EXPIRED to IntentStatus enum; add optional expiresAt to Intent - config: add credits.intentExpiryMinutes (default 10, env INTENT_EXPIRY_MINUTES) - errors: add GoneError (HTTP 410) for expired-intent responses - intentsRepository: persist/hydrate expires_at; add getExpiredPendingIntents() - IntentsUseCases: - createIntent sets expiresAt = now + intentExpiryMinutes - getIntent / triggerWatchIntent reject expired intents with GoneError - legacy intents without expiresAt are never treated as expired - add cleanupExpiredIntents() to mark stale PENDING rows as EXPIRED - creditExpiryJob: new service that runs markExpiredCredits() + cleanupExpiredIntents() on config.credits.expiryCheckIntervalMs interval - frontendWorker: start/stop creditExpiryJob alongside paymentManager when buyCredits flag is active - tests: expand intents.spec.ts with expiry, GoneError, legacy-row, and cleanupExpiredIntents coverage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(pay-with-ai3): gate buyCredits on Google auth for public launch (Phase 1, Task 1.4) Changes the feature flag evaluation so the two operating modes are explicit: - Staff mode (BUY_CREDITS_ACTIVE=false, BUY_CREDITS_STAFF_ONLY=true) Access granted via STAFF_DOMAINS / STAFF_USERNAME_ALLOWLIST config (currently @subspace.network and @autonomys.xyz). No change to existing behaviour. - Public mode (BUY_CREDITS_ACTIVE=true) Access granted only to users authenticated via Google OAuth (oauthProvider === 'google'). All other providers — GitHub, Discord, web3-wallet, etc. — are blocked. This prevents Sybil purchases without requiring a separate account-linking flow. Additionally adds a defense-in-depth check on POST /intents that returns 403 GOOGLE_AUTH_REQUIRED if the active flag is set and the user is not on Google, ensuring a clear error even if the middleware is somehow bypassed. Files changed: - core/featureFlags/index.ts: add hasGoogleAuth(); update isActive() - app/controllers/intents.ts: add explicit Google auth guard on POST / - __tests__/unit/useCases/featureFlags.spec.ts: new test suite (14 cases) covering active/staffOnly/disabled modes and all provider variants Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(feature-flags): scope Google auth gate to buyCredits only The isActive function was applying the Google OAuth requirement to all feature flags, causing taskManager and objectMappingArchiver to return false for non-Google users even when active=true. Now only the buyCredits flag uses the Google auth and staff-only gates; all other flags return their active value directly. Made-with: Cursor * fix(feature-flags): restore generic staffOnly support for all flags The buyCredits-specific refactor silently dropped staffOnly handling for non-buyCredits flags — the fallthrough path returned only value.active. Restructure isActive so the staffOnly check applies to every flag. Made-with: Cursor --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Exposes four authenticated REST endpoints under /credits, gated by the
buyCredits feature flag (same middleware as /intents):
GET /credits/summary — user's remaining bytes, next expiry,
canPurchase flag, maxPurchasableBytes,
and googleVerified status
GET /credits/batches — full purchase history (incl. expired rows)
GET /credits/batches/expiring — active rows expiring within 30 days
GET /credits/economics — admin-only system-wide expiry stats
Key design decisions:
- All responses are scoped to the authenticated user via JWT (no accountId
in the URL); /economics is further gated by UserRole.Admin.
- canPurchase / maxPurchasableBytes use the larger of upload vs download
remaining as the binding constraint, since each purchase grows both equally.
- BigInt fields are serialised as strings on the wire (matches intents pattern).
- Added getExpiringCreditsByAccountId(accountId, withinDays) to the
purchasedCredits repository — per-user variant of the existing system-wide
getExpiringCredits, needed for the /batches/expiring endpoint.
- 14 unit tests covering balance calculations, asymmetric byte scenarios,
cap-boundary edge cases, admin/non-admin gating, and 30-day window.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…all rows getExpiringCredits() was doing SELECT * and pulling every system-wide expiring credit row into Node.js memory, only to reduce them for a count and two sums. Replace with a single SQL COUNT/SUM aggregate query to avoid unbounded memory usage as credit purchases grow. Made-with: Cursor
…te retry When a confirmed intent cannot be converted to credits because the user is already at the 100 GiB per-user cap, onConfirmedIntent previously returned an error — causing the payment manager polling loop to retry the same intent every 30 seconds forever while the user's money sat on-chain with no credits granted. ## What changes **`packages/models`** — adds `OVER_CAP = 'over_cap'` to `IntentStatus`. No migration needed: the intents.status column is VARCHAR(32) with no check constraint, so the new string value is accepted as-is. **`intentsRepository`** — adds `getOverCapIntents()` returning all rows with `status = 'over_cap'`, ordered by id. **`IntentsUseCases.onConfirmedIntent`** — when `addCreditsToAccount` returns a `ForbiddenError` (cap exceeded), the intent is now marked `OVER_CAP` and the function returns `ok()` instead of `err()`. The polling loop stops retrying because it only processes `CONFIRMED` rows. Non-ForbiddenError failures still propagate as errors so they continue to be retried (those represent transient infrastructure failures, not a permanent business-rule block). **`IntentsUseCases.getOverCapIntents(executor)`** — admin-only use case that lists all OVER_CAP intents for review. Returns `ForbiddenError` for non-admin callers. **`GET /intents/over-cap`** (admin only) — new HTTP endpoint that surfaces stuck intents so the team can decide whether to adjust a user's cap and reprocess, or arrange an out-of-band refund. ## What admins can do with this An OVER_CAP intent contains the userPublicId, paymentAmount, and the shannonsPerByte price at which bytes were calculated. From there the admin can: - Bump the user's cap via the existing `POST /accounts/update` endpoint and re-queue the intent by flipping it back to CONFIRMED in the DB. - Arrange an out-of-band on-chain refund via the treasury contract. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rocess When a confirmed intent cannot be converted to credits because the user is already at the 100 GiB per-user cap, onConfirmedIntent previously returned an error — causing the payment manager polling loop to retry the same intent every 30 seconds forever while the user's money sat on-chain with no credits granted. **`packages/models`** — adds `OVER_CAP = 'over_cap'` to `IntentStatus`. No migration needed: the intents.status column is VARCHAR(32) with no check constraint. **`errors/index.ts`** — adds `ConflictError` (HTTP 409) for operations that are valid but applied to a resource in the wrong state. **`intentsRepository`** — adds `getOverCapIntents()` returning all rows with `status = 'over_cap'`, ordered by id. **`IntentsUseCases.onConfirmedIntent`** — when `addCreditsToAccount` returns a `ForbiddenError` (cap exceeded), the intent is now marked `OVER_CAP` and the function returns `ok()` instead of `err()`. The polling loop stops retrying because it only fetches `CONFIRMED` rows. **`IntentsUseCases.getOverCapIntents(executor)`** — admin-only use case listing all OVER_CAP intents for review. **`IntentsUseCases.reprocessOverCapIntent(executor, intentId)`** — admin resets a single OVER_CAP intent back to CONFIRMED so the polling loop re-attempts credit grant on its next tick (~30 s). Returns ConflictError if the intent is not OVER_CAP, preventing accidental re-queuing. **Controller** — adds two admin-only endpoints: - `GET /intents/over-cap` list stuck intents - `POST /intents/:id/reprocess` re-queue after cap is raised Also fixes a route-ordering bug: the static `GET /over-cap` route was registered after the dynamic `GET /:id` route, causing Express to match `GET /intents/over-cap` as `id = 'over-cap'`. Static routes are now registered before dynamic ones. 1. User's payment arrives on-chain but cap is hit → intent becomes OVER_CAP. 2. Admin calls `GET /intents/over-cap` to see the stuck intent with its userPublicId and paymentAmount. 3. Admin calls `POST /accounts/update` to raise the user's cap. 4. Admin calls `POST /intents/:id/reprocess` to flip status back to CONFIRMED. 5. Polling loop picks it up within ~30 s and credits land automatically. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The two OVER_CAP tests used `await import('neverthrow')` inside the
test body to obtain the `err` helper. Dynamic imports in Jest's ESM
mode (--experimental-vm-modules) can cause module-caching surprises.
Replaced with a static `import { ok, err } from 'neverthrow'` at the
top of the file — simpler and unambiguous.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Test fixtures used role: 'admin' / 'user' (lowercase) but UserRole enum values are 'Admin' / 'User' (capitalized), causing admin role checks to always fail and 5 tests to get ForbiddenError instead of proceeding. Made-with: Cursor
07a – Cap pre-purchase guard
- isPackageOverCap() utility compares a package's byte size against
maxPurchasableBytes from GET /credits/summary.
- Named packages that exceed the remaining cap are disabled (opacity-50,
pointer-events-none) and show an "Exceeds cap" badge.
- A top-level amber banner is shown when canPurchase === false.
- The "custom" package is never disabled here; exact-amount validation
is left to Step 2 where the user enters a value.
- Free-tier users (creditSummary === null) are never blocked.
07b – Fix over_cap terminal state in useTransactionConfirmation
- The polling loop now returns early when intent.status === 'over_cap',
sets isOverCap=true, and stops polling instead of spinning forever.
- Step3_TransferTokens surfaces a clear "Credit cap reached" error box
and disables the Continue button when isOverCap is true.
- Both queryKey caches (account + creditSummary) are invalidated on
a successful completed transition so balances refresh instantly.
07c – GET /credits/summary integrated into the frontend
- CreditSummaryResponse and ExpiringCreditBatch wire types added to
api.ts; getCreditSummary() and getExpiringCreditBatches() methods
added to the API service.
- creditSummary field + setCreditSummary action added to the Zustand
user store (version bump handled by persist config).
- SessionEnsurer now queries GET /credits/summary every 30 s and
stores the result; the query key is 'creditSummary'.
07d – Fix "No expiration" copy
- All three named packages now show "Credits valid for 90 days"
(CREDIT_EXPIRY_DAYS constant) instead of the incorrect "No expiration".
07e – Expiry warning banner
- New ExpiryWarningBanner atom queries GET /credits/batches/expiring
(refreshed every 5 min) and renders an amber warning with the soonest
expiry date and total expiring bytes.
- Banner is injected in the drive layout, above {children}.
Tests (24 total, all passing):
- credits.spec.ts: 16 cases covering isPackageOverCap, daysUntilExpiry,
and sumExpiringUploadBytes edge cases including zero cap, exact match,
large values, partial days, negative days, and null inputs.
- useTransactionConfirmation.spec.ts: 8 cases verifying that
completed and over_cap both stop polling, are mutually exclusive,
and that all other statuses (pending, confirmed, failed, expired)
continue polling.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nv-var Previously the UI hardcoded "Credits valid for 90 days" as a compile-time constant. If the operator changes CREDIT_EXPIRY_DAYS in their deployment the frontend would silently show stale copy. Changes: - Add `expiryDays: number` to the CreditSummary type and GET /credits/summary response so the backend config value is propagated to the frontend. - Add `expiryDays` to CreditSummaryResponse wire type in api.ts. - Step1_SelectPackage now reads creditSummary.expiryDays (falling back to DEFAULT_EXPIRY_DAYS=90 only before the first API response) instead of a hardcoded constant. - Package features array refactored: static baseFeatures are defined at module level; the expiry string is appended at render time using the runtime value from the API. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The polling effect checked isBackendCompleted to prevent re-entry after the completed terminal state but omitted the symmetric check for isOverCap. If a dependency changed reference after over_cap was detected, the effect re-fired and made a redundant API call. Made-with: Cursor
…ed credits in banner daysUntilExpiry could return 0 or negative values when a batch expired between refetch intervals, producing confusing text like "will expire in 0 days". The utility now clamps to Math.max(0, ...) and the banner shows "today" instead of interpolating zero/negative day counts. Made-with: Cursor
The function clamps to non-negative via Math.max(0, ...), so a past expiry date returns 0, not a negative number. The test expectation was toBeLessThan(0) which always fails. Made-with: Cursor
- Parse backend error body in promote API call so 30-day notice message triggers the override flow correctly - Add w-screen to interstitial so it centers properly within flex layout - Invalidate touStatus query after admin promote/activate/archive actions to avoid needing a hard refresh - Clear touStatus from Zustand store on signOut to prevent stale interstitial rendering after decline - Gate interstitial on active session to prevent render loop after signOut Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use transactional activation (archive + activate in single BEGIN/COMMIT) to prevent inconsistent state if the second DB call fails - Remove misleading totalActiveUsers field from TouVersionWithStats since users are in the auth DB and the value was hardcoded to 0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Show loading state while touStatus query is in-flight to prevent briefly rendering protected content before the interstitial appears - Remove unused getActiveVersion repository function (all callers use ensureActiveVersion instead) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ions - getTouStatus now throws on API errors instead of returning accepted:true, so the loading gate in SessionEnsurer blocks content until status resolves - Replace non-null assertions (promoted!, activated!, archived!) with explicit null checks that return NotFoundError on TOCTOU races Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The banner component renders body as plain text, so URLs aren't clickable. Replace with a message directing users to the interstitial where they can review and accept the changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…it-history Feat/pay with ai3/step 09 credit history
Explains the draft -> pending -> active lifecycle, material vs non-material change behavior, and the 30-day notice requirement. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ent-robustness Feat/pay with ai3/step 10 payment robustness
markIntentAsConfirmed previously performed an unconditional UPDATE,
meaning a duplicate call could overwrite an intent that was already
CONFIRMED, COMPLETED, or OVER_CAP — resetting it back to CONFIRMED and
potentially triggering a second credit grant on the next polling tick.
Duplicate calls arise legitimately from:
• chain reorganisations causing the same IntentPaymentReceived event
to be re-emitted
• the payment manager reconnecting after downtime and re-processing
event logs it already handled
• watchTransaction and the _checkConfirmedIntents polling fallback
racing each other on the same intent
The fix reads the current status before writing. If the intent is
already past the PENDING stage (CONFIRMED, COMPLETED, or OVER_CAP) the
function returns ok() without touching the row, so the caller does not
treat a duplicate as a failure and does not schedule a retry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getIntentCredits() divides paymentAmount by shannonsPerByte using BigInt integer division. A payment smaller than one shannonsPerByte produces 0 credits. Previously onConfirmedIntent would call addCreditsToAccount(0), mark the intent COMPLETED, and give the user nothing — a misleading outcome that silently discards the payment and wastes a DB row. The fix computes creditBytes before calling addCreditsToAccount and returns an error if the result is zero. The polling loop will retry on the next tick, giving operators visibility through the error log rather than a silent no-op COMPLETED status. In normal operation this guard should never fire: the frontend enforces a minimum package size well above one byte. It defends against buggy clients or unexpected future changes to the shannonsPerByte price. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
If the payment manager restarted (or the EVM RPC was unavailable) while a user's on-chain transaction was in flight, the intent stays PENDING with its tx_hash set. The cleanup job correctly skips these rows (a PENDING+txHash intent is not abandoned — it is being watched), but the payment manager's polling loop only queries CONFIRMED intents, so the transaction is never re-processed. The user has paid on-chain but receives no credits. This commit adds a startup recovery sweep: intentsRepository: new getPendingWithTxHash() query — selects PENDING intents with a non-null tx_hash. IntentsUseCases: exposes getPendingWithTxHash() on the use-case layer. paymentManager._recoverOrphanedTransactions(): on startup, fetches all PENDING+txHash intents and calls watchTransaction() for each one. waitForTransactionReceipt() returns immediately for already-mined transactions, so the sweep completes quickly in the happy path. Errors are caught per-intent with Promise.allSettled() so a single bad tx does not abort recovery of the others. paymentManager.start(): fires the recovery sweep asynchronously (non-blocking) before starting the polling interval and event watcher. Safety: markIntentAsConfirmed is now idempotent (previous commit), so a sweep that re-discovers an already-CONFIRMED intent is a no-op. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The purchased-credits feature introduced 10 environment variables that were absent from .env.sample, making it impossible for a new operator to configure the payment pipeline without reading config.ts directly. Added with defaults and descriptions: BUY_CREDITS_ACTIVE — master feature flag (default: false) BUY_CREDITS_STAFF_ONLY — staged-rollout flag (default: false) EVM_CHAIN_ENDPOINT — Auto-EVM RPC URL EVM_CHAIN_CONTRACT_ADDRESS — AutoDriveCreditsReceiver address EVM_CHAIN_CONFIRMATIONS — block confirmations before finality (6) EVM_CHAIN_CHECK_INTERVAL — polling fallback interval ms (30 000) CREDITS_PRICE_MULTIPLIER — markup on consensus byte fee (5.00) CREDIT_EXPIRY_DAYS — days until a credit batch expires (90) MAX_CREDITS_PER_USER — per-user purchased-credit cap (100 GiB) CREDIT_EXPIRY_CHECK_INTERVAL — background job interval ms (3 600 000) INTENT_EXPIRY_MINUTES — price-lock window minutes (10) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Surfaces all pay-with-AI3 purchase data in the admin UI so operators
can monitor credit health and resolve stuck payments without needing
direct DB access.
Backend
- purchasedCreditsRepository: add getAllWithUserPublicId() — joins
purchased_credits with intents to return every batch with its
owner's userPublicId, newest-first.
- CreditsUseCases: add getAllBatches(executor) — admin-only wrapper
returning ForbiddenError for non-admins.
- creditsController: add GET /credits/batches/all — serialises
bigint fields to strings and includes userPublicId on each row.
Frontend (api.ts)
- Add AdminCreditBatch, CreditEconomicsResponse, OverCapIntent types.
- Add getAdminCreditBatches(), getCreditEconomics(), getOverCapIntents(),
reprocessIntent() service methods.
Frontend (AdminPanel/AdminCredits.tsx — new component)
- Economics card: 3-metric summary (batch count, upload bytes,
download bytes) for credits expiring within 30 days.
- Over-Cap panel: table of OVER_CAP intents with a Reprocess button
per row; invalidates queries on success.
- All Batches table: every purchase across all users with user,
status badge, purchased date, original/remaining bytes, usage bar,
and expiry date.
Frontend (AdminPanel/index.tsx)
- Render <AdminCredits /> between the analytics section and the
users table.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…trying The dust payment guard returned err() without moving the intent to a terminal state, leaving it CONFIRMED. Since _checkConfirmedIntents polls every 30s and paymentAmount/shannonsPerByte are immutable, this caused an infinite retry loop. Now marks the intent FAILED (terminal), matching the OVER_CAP pattern. Made-with: Cursor
…d state The button's disabled/label state relied solely on useMutation's `variables`, which persists after the mutation settles (including on error). Now checks `isPending && reprocessingId` so the button re-enables as soon as the mutation completes. Made-with: Cursor
…dits getBatchStatus, STATUS_CLASSES, STATUS_LABEL, and BatchStatus were duplicated between CreditHistory and AdminCredits. The AdminCredits copy also reimplemented the days-until-expiry calculation inline (missing the Math.max(0, …) clamp). Consolidate into utils/credits.ts so both views share a single source of truth and add unit tests for getBatchStatus. Made-with: Cursor
…mpotency guard The guard only checked CONFIRMED, COMPLETED, and OVER_CAP, allowing a chain reorg to overwrite a FAILED (dust-payment) or EXPIRED intent back to CONFIRMED — creating a perpetual confirm→fail cycle with unnecessary DB writes. Made-with: Cursor
…cceptance # Conflicts: # apps/frontend/src/services/api.ts
feat: ToU versioning, acceptance tracking, and re-consent flow
✅ Deploy Preview for auto-drive-storage ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
@claude review |
|
Claude finished @EmilFattakhov's task in 7m 49s —— View job Code Review: Pay with AI3 FeatureTasks:
OverviewThis is a substantial feature (100 files, ~10K lines) implementing end-to-end token-based credit purchases (AI3 → storage bytes), a Terms of Use versioning system, and a Banner notification system. The engineering quality is high overall — with thoughtful handling of financial integrity, race conditions, and idempotency. The review below focuses on issues found rather than what works well. Issues Found🔴 High —
|
| Severity | Count |
|---|---|
| 🔴 High | 2 |
| 🟡 Medium | 4 |
| 🟢 Low | 3 |
The implementation is solid and demonstrates careful attention to financial integrity — the advisory-lock cap check, compensating refund on ledger failure, TOCTOU-safe intent expiry, and orphan transaction recovery on startup are all well-thought-out. The main items to address before production are the timestamp vs timestamp with time zone migration issue and the acceptCurrentVersion idempotency gap.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR
| InteractionSource.FreeTier, | ||
| cid, | ||
| ) | ||
| } |
There was a problem hiding this comment.
Free-tier recording failure leaks consumed purchased credits
Medium Severity
When an interaction splits across purchased and free-tier credits, the free-tier createInteraction call at the end of registerInteraction has no try/catch with compensating refund. If it fails after the purchased createInteraction already succeeded, the consumed purchased credits are permanently lost — the purchased interaction ledger entry exists but the overall operation (upload) fails. The purchased-credit path correctly refunds on failure, but this symmetric protection is missing for the subsequent free-tier path.
Additional Locations (1)
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
Warning Review the following alerts detected in dependencies. According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.
|
|
Closed as non-intended to be merged |


PR where the baseline is the SHA just before the pay with AI3 feature was introduced to the codebase.
The changes reflect all the work that has been completed and represent all feature end-to-end.