Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ RATE_LIMIT_FALLBACK_MAX_BUCKETS=10000
# -----------------------------------------------------------------------------
# Billing concurrency control
# -----------------------------------------------------------------------------
# Timeout for a billing deduction (milliseconds).
BILLING_TIMEOUT_MS=5000
# Maximum concurrent billing deduct operations allowed per developer.
# Set this to 1 for fully serialized deducts, or higher to allow limited
# parallelism per developer.
Expand Down Expand Up @@ -169,6 +171,8 @@ KEY_SEMAPHORE_TTL_MS=300000
IDEMPOTENCY_RETENTION_WINDOW_SECONDS=86400
# How often the idempotency sweeper job runs (milliseconds).
IDEMPOTENCY_SWEEPER_INTERVAL_MS=60000
# Maximum UTF-8 bytes stored for one cached response (256 KiB).
IDEMPOTENCY_MAX_BODY_BYTES=262144

# -----------------------------------------------------------------------------
# CORS — comma-separated list of allowed origins
Expand Down Expand Up @@ -248,8 +252,20 @@ SOROBAN_BILLING_DEDUCT_FN=deduct
SOROBAN_BILLING_RPC_TIMEOUT_MS=5000
# Stellar secret key used to submit fee-bumped transactions. Default: unset; fee bumping fails closed when missing.
FEE_BUMPER_SECRET_KEY=
# Fee-abstraction payment trust anchors. Uncomment with valid G... public keys
# before using quote/execution; missing anchors fail closed, without enabling signing.
# FEE_ABSTRACTION_TREASURY_ADDRESS=G...
# FEE_ABSTRACTION_APP_TOKEN_ISSUER=G...
FEE_ABSTRACTION_APP_TOKEN_ASSET_CODE=APP
# App-token units per XLM: positive integer ratio; fractional units round up.
FEE_ABSTRACTION_RATE_NUMERATOR=1
FEE_ABSTRACTION_RATE_DENOMINATOR=10
FEE_ABSTRACTION_QUOTE_TTL_SECONDS=300
# Must stay below BILLING_TIMEOUT_MS (default 5000) when that deadline is enabled.
FEE_ABSTRACTION_PAYMENT_VERIFY_TIMEOUT_MS=4000

# -----------------------------------------------------------------------------

# Horizon (optional — set HORIZON_ENABLED=true to activate)
# -----------------------------------------------------------------------------
HORIZON_ENABLED=false
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ jobs:
- run: npm ci
- name: Generate Prisma client
run: npx prisma generate
- name: Typecheck (application sources)
run: npx tsc -p tsconfig.build.json --noEmit
- name: Lint repository
run: npm run lint
- name: Typecheck application and test contracts
run: npm run typecheck
- name: Build
run: npm run build
- name: Verify build artifacts
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,17 @@

### Fixed

- Fixed SQLite migration coherence: `0035_credit_grant_receipts` is now a normal ledgered migration. The db module no longer creates the table on open; the runner applies legacy `<= 0021` files by `_migrations` checksum, skips PostgreSQL-only files by dialect, and production validation fails closed unless every planned migration is recorded.
- Keep transient and reconciliation responses retryable instead of caching them under an idempotency key; preserve lease fencing and byte-faithful encoded replay, inspect compressed JSON under a bounded byte budget, and replay only approved headers.
- Scope billing status and duplicate-request lookups to the authenticated payer, reject body-supplied payer overrides, and prevent another account's request ID from exposing or mutating its charge.
- Keep bulk quota updates atomic when a later developer lookup fails, rolling back earlier changes in the same SQLite transaction.
- Restore application and test type contracts and run repository lint and typechecking in CI.
- Keep fee-bump quotes equal to signed envelope fees, including high inner fees and Soroban resource fees, and read the documented `FEE_BUMPER_SECRET_KEY` signer setting.
- Require a verified, quote-bound on-chain app-token payment before fee signing; consume quote/payment once in a database transaction, enforce expiry with the database clock, and round fractional token charges up to payable units.
- Classify PostgreSQL-only quote and replay-header migrations correctly during SQLite demo bootstrap; production SQL remains unchanged.
- Require fresh, method/path-bound v1 forwarded identity signatures, reject replay with bounded memory or shared PostgreSQL storage, and restrict legacy migration mode to non-production.
- Refund grants record a durable SQLite receipt per (scope, idempotency key) in the same transaction as the balance change. A same-key retry after an over-cap response, which is not cached, now returns the stored result instead of crediting again; a different request body returns `409 IDEMPOTENCY_KEY_REUSE_MISMATCH`.
- Fee-abstraction requests cancelled by the billing deadline before fee signing roll back the quote consumption, leaving the quote `pending` so the same quote can be retried with its payment. Verification now shares the request deadline (`FEE_ABSTRACTION_PAYMENT_VERIFY_TIMEOUT_MS`, default `4000`), which must stay below `BILLING_TIMEOUT_MS` or the request fails before quoting or payment. Cancellation after `COMMIT` does not make HTTP delivery atomic with the consumed quote.

- Propagated `X-Correlation-Id` across the quota self-service routes and outbound webhook dispatches so quota requests and related notifications can be traced end-to-end.
- Removed a broken, unmounted CORS middleware call and a duplicate import from `src/routes/billing.ts` that were left over from a conflicted merge and failed to compile.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Configuration is validated with Zod at startup (`src/config/env.ts`); the server
| `ADMIN_API_KEY` | yes | Key for `/api/admin/*` (`x-admin-api-key` header) |
| `METRICS_API_KEY` | yes | Key for `/api/metrics` |
| `UPSTREAM_URL`, `UPSTREAM_HOST_ALLOWLIST` | prod | Default upstream and allowed upstream hosts for the proxy (SSRF guard) |
| `SOROBAN_BILLING_CONTRACT_ID` | for billing | Billing contract; falls back to the selected network's `STELLAR_*_VAULT_CONTRACT_ID`. Billing is unavailable when neither is configured |
| `CORS_ALLOWED_ORIGINS` | no | Comma separated browser origins |
| `DEMO_MODE` | no | `true` = ephemeral SQLite + seeded catalog (see below) |
| `SQLITE_PATH` | no | SQLite file path (default `./database.db`; `/tmp/callora.db` on Vercel) |
Expand Down
32 changes: 20 additions & 12 deletions docs/api-proxy-idempotency.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Idempotency records are retained for **24 hours** by default. This means:

**HTTP 2xx** (status from upstream)

The request is forwarded to the upstream service. The response (status, headers, body) is cached for the Idempotency-Key.
The request is forwarded to the upstream service. Cacheable responses (status, body and allowlisted headers) are stored for the Idempotency-Key; transient outcomes are not.

**Header in response**:
- No special header is added on first request; the gateway behaves transparently.
Expand Down Expand Up @@ -296,15 +296,23 @@ cannot be undone by this mechanism; upstream operations still need their own
idempotency when retried after a crash or disconnect.

Responses complete on `finish`, including empty 204s, `sendStatus`, `end(chunk)`
and streams. Non-5xx status caching is unchanged. Replays preserve status, body
bytes, content type and content encoding; other upstream headers are not stored.
5xx and failed saves release the lease (with logged errors on persistence
failure); an unavailable database may delay recovery until lease expiry.

Only responses up to **256 KiB** are buffered for replay. Larger responses store
a SHA-256 hash and byte count. Retrying returns **409
`IDEMPOTENCY_RESPONSE_TOO_LARGE`** with those details and an instruction to fetch
the resource by identifier, rather than execute the operation again. This
response includes `Idempotent-Replayed: true`. See
[billing idempotency](billing-idempotency.md#http-idempotency-leases-and-response-replay)
and streams. Only deterministic outcomes are stored: 2xx and 400, 401, 403, 404,
409, 410 and 422, per the [billing response cache policy](billing-idempotency.md#http-response-cache-policy).
Replays preserve status, body bytes, and only `content-type`, `content-encoding`,
`location`, `etag`, `retry-after` and `x-request-id`; encoding preserves the captured
wire representation, while `set-cookie` and unrelated upstream headers are never stored.
Any other outcome, including 5xx, 408, 425, 429, reconciliation and in-progress
responses, deletes the started record so the next retry is forwarded upstream.
Failed saves also release the lease (with logged errors on persistence failure);
an unavailable database may delay recovery until lease expiry.

Bodies above **256 KiB** (`IDEMPOTENCY_MAX_BODY_BYTES`) are not stored: the record is
deleted, a warning with the route and size is logged, and a retry is forwarded upstream
again. Use upstream operations that are safe to repeat for responses that may exceed the
cap. See [billing idempotency](billing-idempotency.md#http-idempotency-leases-and-response-replay)
for migration and deployment requirements.

Compressed JSON (`gzip`, `deflate`, `br`) is decoded under the same byte budget
for transient-code policy checks. The original encoded bytes and encoding are
replayed unchanged when eligible; a decoding failure or decoded-size overflow
releases the key rather than caching an uninspectable response.
86 changes: 75 additions & 11 deletions docs/billing-idempotency.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,72 @@

The billing system implements idempotent deductions to prevent double charges when requests are retried. This is critical for financial operations where duplicate charges can cause serious issues.

## HTTP response cache policy

The HTTP middleware scopes `Idempotency-Key` (or `idempotencyKey` in the body)
to the authenticated principal and request fingerprint. This cache is separate
from the billing service's `requestId` protection against duplicate deductions.

| Response | Cached by default |
| --- | --- |
| 2xx | Yes |
| 400, 401, 403, 404, ordinary 409, 410, 422 | Yes |
| 408, 425, 429, and 5xx | No |
| Other statuses, including 3xx and 402 | No |
| Body `code` or `error.code` is `RECONCILIATION_REQUIRED` or `IDEMPOTENCY_IN_PROGRESS` | Never |
| Stored response body exceeds `IDEMPOTENCY_MAX_BODY_BYTES` | Never |

Non-cacheable responses delete their started cache record, so a retry with the
same key reaches the handler after the temporary condition clears. A completed
replay retains the original status and body and sets `Idempotent-Replayed: true`.
The pure exported `isCacheableIdempotencyResponse` function implements the policy.
Routes can supply a pure `IdempotencyConfig.cacheableStatus(status)` predicate to
restrict or extend the eligible statuses; it cannot override transient statuses,
transient body codes, or the size cap. A 402 therefore remains retryable after
funding changes, while a cached 422 requires a corrected payload and a new key.

Only `content-type`, `content-encoding`, `location`, `etag`, `retry-after`, and the original
`x-request-id` are persisted and restored. The encoding is required to interpret
the captured bytes correctly, including records written by the earlier lease
implementation. `set-cookie` and all other response headers are excluded. The
original request ID identifies the cached response; the retry's tracing context remains separate.

`IDEMPOTENCY_MAX_BODY_BYTES` is a positive integer, defaults to **262144 bytes
(256 KiB)**, and measures the response body bytes as sent. Bodies exactly at
the limit are eligible; larger bodies are not stored. Oversized responses log
only the route, byte count, and configured cap, never the body or headers.

Policy inspection decodes `gzip`, `deflate`, and `br` before checking transient
JSON body codes; stored response bytes remain unchanged. Decoding is bounded by
the same byte cap. Unsupported/invalid encodings or decoded bodies exceeding
that inspection budget release the owned key instead of retaining an unsafe reply.

Apply `0033_idempotency_response_headers.sql` after `0028_idempotency_lease` and before deploying the updated
middleware. The nullable JSONB column preserves existing rows: records without
header metadata still replay their status and body. The paired down migration
removes only header metadata. Retention, concurrent-key ownership, and the
separate started-record lease policy are unchanged.

Deployment remains blocked until the existing three published `0026_*`
migrations have a data-safe numbering/history resolution. Do not rename or
replay an applied migration to make layout validation pass; the usage-status
backfill can overwrite later reconciliation decisions. The required deployment
history clarification is tracked in [issue #1499](https://github.com/CalloraOrg/Callora-Backend/issues/1499#issuecomment-6285619975).

## Refund grant receipts

`POST /api/billing/refund` requires `Idempotency-Key`. The idempotency middleware namespaces that key by principal and fingerprints the request. Responses at or under `IDEMPOTENCY_MAX_BODY_BYTES` are cached and replayed; larger responses are not cached, so a retry reaches the handler again. To keep that retry from granting twice, each grant writes a receipt to `credit_grant_receipts` (migration `0035_credit_grant_receipts.sql`), keyed by `(scope, idempotency key)`, in the same SQLite transaction as the balance change.

- Same key and same request fingerprint: the stored grant result is returned and the balance is unchanged.
- Same key and a different request: `409 IDEMPOTENCY_KEY_REUSE_MISMATCH`, no grant.

Receipts are business records, not HTTP responses, and exist only on the SQLite backend where credits live. The down migration refuses to drop a non-empty receipts table.

## How It Works

### Idempotency Key

Every billing deduction request must include a unique `request_id` idempotency key). This key is used to identify duplicate requests.
Every billing deduction request must include a stable `requestId`. Reuse it when retrying the same deduction. The optional HTTP `Idempotency-Key` header or `idempotencyKey` body field controls the response cache described above.

```typescript
interface BillingDeductRequest {
Expand Down Expand Up @@ -217,7 +278,7 @@ const requestId = `req_${uuidv4()}`;

```typescript
// Check if a request was already processed
const existing = await billingService.getByRequestId('req_abc123');
const existing = await billingService.getByRequestId('req_abc123', user.id);

if (existing) {
console.log('Request already processed');
Expand Down Expand Up @@ -731,22 +792,25 @@ save over or delete a replacement owner's record.

Completion is recorded on the response `finish` event, including `json`,
`send`, `sendStatus`, empty `204 end()`, `end(chunk)`, and streaming responses.
Non-5xx responses remain cacheable; 5xx responses release the key. A connection
Only responses allowed by the HTTP response cache policy are stored. Every other outcome, including 5xx, 408, 425, 429 and transient body codes, releases the key so the next retry executes. A connection
`close` without `finish` releases only that request's lease for immediate retry.
A failed completion write is logged and cleanup is attempted; if the database
is unavailable, lease expiry still allows recovery without waiting 24 hours.

Replay retains status, raw body bytes, content type and content encoding. At
most 256 KiB of response bytes are buffered. Larger responses store only size
and a SHA-256 hash, then return `409 IDEMPOTENCY_RESPONSE_TOO_LARGE` on retry
(with `Idempotent-Replayed: true`), instructing the client to fetch the resource
instead. For billing, use `GET /api/billing/deduct/request/:requestId` with the
original request identifier. Do not generate a new identifier to retry a charge.
Replay retains status, raw body bytes, and the allowlisted headers
(`content-type`, `content-encoding`, `location`, `etag`, `retry-after`, `x-request-id`). Bodies above
`IDEMPOTENCY_MAX_BODY_BYTES` are never stored: the row is deleted, a warning with
the route and size is logged, and a retry executes the handler again. Hash-only
rows left by the superseded retention path still return `409
IDEMPOTENCY_RESPONSE_TOO_LARGE` until they expire, so an operation that already
ran is never re-executed. Billing clients can look up a deduction with
`GET /api/billing/deduct/request/:requestId`; do not generate a new identifier
to retry a charge.

The lease prevents stale database writes, but cannot cancel an external side
effect after a disconnect or crash. Billing's persistent request identifier and
Soroban idempotency remain necessary. Apply migration `0028_idempotency_lease`
and drain old application instances before enabling takeover: old binaries do
Soroban idempotency remain necessary. Apply migrations `0028_idempotency_lease`
and `0033_idempotency_response_headers`, and drain old application instances before enabling takeover: old binaries do
not fence writes. Existing completed JSON records remain replayable. On rollback,
roll back the application before removing the lease column; new response-format
records are not readable by the old middleware and must age out before rollback.
Loading