Skip to content

Claude Review PR - Pay with AI3 feature review - Not intended to be merged - #638

Closed
EmilFattakhov wants to merge 114 commits into
feature-review-baselinefrom
main
Closed

Claude Review PR - Pay with AI3 feature review - Not intended to be merged#638
EmilFattakhov wants to merge 114 commits into
feature-review-baselinefrom
main

Conversation

@EmilFattakhov

Copy link
Copy Markdown
Member

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.

EmilFattakhov and others added 30 commits March 2, 2026 20:28
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 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
jim-counter and others added 21 commits March 30, 2026 16:54
- 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
@netlify

netlify Bot commented Mar 31, 2026

Copy link
Copy Markdown

Deploy Preview for auto-drive-storage ready!

Name Link
🔨 Latest commit ca24477
🔍 Latest deploy log https://app.netlify.com/projects/auto-drive-storage/deploys/69cbf34f8a0b5f00087b6bcd
😎 Deploy Preview https://deploy-preview-638--auto-drive-storage.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@EmilFattakhov EmilFattakhov changed the title Temp PR (Not intended to be merged) Claude Review PR - Pay with AI3 feature review - Not intended to be merged Mar 31, 2026
@EmilFattakhov

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Mar 31, 2026

Copy link
Copy Markdown

Claude finished @EmilFattakhov's task in 7m 49s —— View job


Code Review: Pay with AI3 Feature

Tasks:

  • Gather context and understand the PR
  • Review database migrations
  • Review backend core logic (credits, intents, accounts)
  • Review backend infrastructure (repositories, services)
  • Review API controllers and routes
  • Review frontend components and state management
  • Review tests
  • Summarize findings

Overview

This 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 — tou_versions table uses timestamp without time zone

File: apps/backend/migrations/sqls/20260330000000-tou-versions-up.sql lines 4–5, 8–9, 24

effective_date timestamp NOT NULL,   -- ❌ should be TIMESTAMP WITH TIME ZONE
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
accepted_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,

The purchased_credits and banners tables correctly use TIMESTAMP WITH TIME ZONE. The ToU tables don't. If the server ever runs in a non-UTC timezone, effective_date comparisons in ensureActiveVersion (which calls effective_date <= NOW()) may activate versions at the wrong time.

Fix this →


🔴 High — acceptCurrentVersion is not idempotent (unhandled UNIQUE violation)

File: apps/backend/src/core/tou.ts lines 81–102

user_tou_acceptance has a UNIQUE (user_id, version_id) constraint. If a user calls POST /tou/accept twice (double-click, network retry, etc.), the second call hits the DB and throws a PostgreSQL unique constraint error which bubbles up as an unhandled 500. The function should check if the user has already accepted and return the existing acceptance idempotently.

Fix this →


🟡 Medium — Redundant ownership check in triggerWatchIntent

File: apps/backend/src/core/users/intents.ts lines 120–128

getIntent() (line 120) already validates ownership at line 96–98 and returns ForbiddenError on mismatch. The redundant check at line 126 (intent?.userPublicId !== executor.publicId) is unreachable — getIntent would have already returned err if the user didn't own the intent. The dead code could mislead future maintainers.

const result = await getIntent(executor, intentId)
if (result.isErr()) {
  return err(result.error)
}
const intent = result.value

// This check is unreachable — getIntent already enforces ownership:
if (intent?.userPublicId !== executor.publicId) { // ← dead code

Fix this →


🟡 Medium — getPrice() can lose precision for large values

File: apps/backend/src/core/users/intents.ts lines 362–377

const BYTES_PER_GB = 1024 * 1024 * 1024   // 1,073,741,824
const SHANNONS_PER_AI3 = 1e18             // 10^18

const getPrice = async () => {
  ...
  return {
    pricePerGB: Math.round((price * BYTES_PER_GB) / SHANNONS_PER_AI3 * 100) / 100,
  }
}

price is a number (from Math.floor(...)) and price * BYTES_PER_GB can easily exceed JavaScript's safe integer limit (Number.MAX_SAFE_INTEGER ≈ 9 × 10¹⁵). When price is on the order of shannons (e.g., 1e7), the product is ~10¹⁶ which loses precision. Using BigInt arithmetic here or being explicit about the units would be safer. Operator precedence also makes the expression subtle — / SHANNONS_PER_AI3 * 100 divides first then multiplies, which is fine but easy to misread.


🟡 Medium — Sequential processing in _checkConfirmedIntents

File: apps/backend/src/infrastructure/services/paymentManager/index.ts lines 74–93

for (const intent of intents) {
  const result = await IntentsUseCases.onConfirmedIntent(intent.id)
  ...
}

onConfirmedIntent calls addCreditsToAccount which does a DB advisory lock + write. Processing all confirmed intents sequentially could cause the 30s check window to be exhausted if many intents accumulate (e.g., after a service restart). Consider Promise.allSettled here, since each intent targets a different account and advisory locks will serialize per-account automatically.


🟡 Medium — getExpiringCredits is dead code in the repository

File: apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts lines 229–244

getExpiringCredits (system-wide list) is exported in the public API but never called anywhere in the codebase — the admin endpoint uses getExpiringCreditsAggregate instead. The per-user variant getExpiringCreditsByAccountId is used correctly. The unused function adds maintenance surface for no benefit.


🟡 Medium — updateBanner double-query ambiguity

File: apps/backend/src/core/banners.ts lines 110–132

When bannersRepository.updateBanner returns null (either "no fields to update" or "banner not found"), the code does a second read to disambiguate. This is brittle — it relies on the repository returning the same null sentinel for two different situations. A cleaner approach would be for the repository to throw NotFoundError directly or return a discriminated result.


🟢 Low — useTransactionConfirmation unwatch double-call on unmount

File: apps/frontend/src/hooks/useTransactionConfirmation.ts lines 57–92

When bounded >= requiredConfirmations, stopRef.current = true is set and unwatch() is called immediately (line 78). Then when the useEffect cleanup runs (on unmount), if (unwatch) unwatch() calls it a second time. While viem's watchBlockNumber unwatch functions are safe to call multiple times, the double-call is unintentional. Setting unwatch = undefined after the first call would make the intent explicit.


🟢 Low — ToU enforcement doesn't block non-material versions

File: apps/frontend/src/components/atoms/SessionEnsurer.tsx lines 98–112

if (
  session?.data &&
  touStatus &&
  !touStatus.accepted &&
  touStatus.currentVersion?.changeType === TouChangeType.Material  // ← only blocks Material
) {

Non-material ToU changes (TouChangeType.NonMaterial) will silently mark accepted: false in the status returned to the client, but the interstitial is never shown. Is this intentional product behaviour (non-material changes don't require re-acceptance)? If so, it should be documented. If not, it's a gap.


🟢 Low — DEFAULT_EXPIRY_DAYS = 90 hardcoded fallback in frontend

File: apps/frontend/src/components/views/PurchaseCredits/steps/Step1_SelectPackage.tsx line 23

const DEFAULT_EXPIRY_DAYS = 90;

This is a sensible fallback before the API loads, but it's a magic number that duplicates the backend default. If the backend default changes it won't be reflected here until the API loads. A comment linking to CREDIT_EXPIRY_DAYS in the backend config would help maintainers keep them in sync.


Summary

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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

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,
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

@socket-security

Copy link
Copy Markdown

@socket-security

Copy link
Copy Markdown

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.

Action Severity Alert  (click "▶" to expand/collapse)
Warn Critical
Critical CVE: npm form-data uses unsafe random function in form-data for choosing boundary

CVE: GHSA-fjxv-7rqg-78g4 form-data uses unsafe random function in form-data for choosing boundary (CRITICAL)

Affected versions: < 2.5.4; >= 3.0.0 < 3.0.4; >= 4.0.0 < 4.0.4

Patched version: 3.0.4

From: ?npm/form-data@3.0.2

ℹ Read more on: This package | This alert | What is a critical CVE?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Remove or replace dependencies that include known critical CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/form-data@3.0.2. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm @webbuf/webbuf is 98.0% likely obfuscated

Confidence: 0.98

Location: Package overview

From: ?npm/@autonomys/auto-drive@1.2.0npm/@webbuf/webbuf@3.0.26

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@webbuf/webbuf@3.0.26. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm entities is 91.0% likely obfuscated

Confidence: 0.91

Location: Package overview

From: ?npm/entities@4.5.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/entities@4.5.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm markdown-it is 91.0% likely obfuscated

Confidence: 0.91

Location: Package overview

From: ?npm/markdown-it@14.1.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/markdown-it@14.1.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@EmilFattakhov

Copy link
Copy Markdown
Member Author

Closed as non-intended to be merged

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.

3 participants