# @stipend-mcp/store-firestore

Firestore-backed `EntitlementStore` **and** `DeliveryStore` for Stipend. Default storage for v1.0 deployments.

- `FirestoreStore` — passes the shared `EntitlementStore` conformance suite (the same suite [`@stipend-mcp/store-memory`](https://github.com/third-eye-technologies/stipend-mcp/tree/main/packages/store-memory) passes and the future `@stipend-mcp/store-postgres` adapter (v1.1) will pass).
- `FirestoreDeliveryStore` — passes the shared `DeliveryStore` conformance suite. Removes the v1.0 launch blocker flagged in Phase 7's `MemoryDeliveryStore` notes (pending deliveries no longer lost on process restart).

## Install

```bash
pnpm add @stipend-mcp/store-firestore firebase-admin
```

`firebase-admin` is a peer-style runtime dependency: this package depends on it directly, but a host that already initialises its own Firebase Admin SDK app should pass in a `Firestore` instance from that app rather than letting this package create its own.

## Configure

The store takes an already-initialised `Firestore` client. Init the Admin SDK once at startup, then construct the store:

```ts
import { initializeApp, cert } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';
import { FirestoreStore } from '@stipend-mcp/store-firestore';

const app = initializeApp({
  projectId: 'my-stipend-deployment',
  credential: cert(JSON.parse(process.env.GOOGLE_SERVICE_ACCOUNT_JSON!)),
});

const firestore = getFirestore(app);
// Optional: if you use a named database other than (default):
// const firestore = getFirestore(app, 'stipend');

const store = new FirestoreStore({ firestore });
```

To target a non-default database, pass the database ID as the second argument to `getFirestore`. The store itself is database-agnostic — it operates on whatever `Firestore` instance it's handed.

### Constructor options

| Option | Type | Default | Purpose |
| --- | --- | --- | --- |
| `firestore` | `Firestore` | required | Pre-configured Admin SDK Firestore client. |
| `collectionPrefix` | `string` | `''` | Prepended to every collection name. Useful for multi-tenant tests against a shared emulator, or for running two deployments side-by-side in one project. |
| `now` | `() => Iso8601` | `nowIso` | Inject a clock for deterministic tests. |

## Collections

The two stores write to nine top-level collections in total (prefixed if `collectionPrefix` is set):

### `FirestoreStore` (EntitlementStore)

| Collection | Document ID | Purpose |
| --- | --- | --- |
| `accounts` | agent ID | One doc per `Account`. The agent ID is used as the document ID so `upsertAccount` is naturally idempotent and `getAccountByAgentId` is a single direct read. `Account.id === Account.agentId` by construction. |
| `entitlements` | UUID | One doc per entitlement, type-discriminated. |
| `payments` | UUID | One doc per payment. |
| `payments_idempotency` | idempotency key | Pointer doc (`{paymentId}`) supporting `findPaymentByIdempotencyKey` and idempotent `createPayment`. Mirrors the SQL `UNIQUE (idempotency_key)` constraint from TDD §2.3. |
| `receipts` | receipt UUID | One doc per signed receipt. |
| `consumptions` | tool-call idempotency key | **The double-spend defense.** The doc ID is the idempotency key, so a second concurrent `checkAndConsume` with the same key collides on `tx.create()` and Firestore retries the transaction; the retry sees the existing consumption and returns `{kind: 'replay'}`. This mirrors the SQL `UNIQUE (tool_call_idempotency_key)` from TDD §2.5. |

`Consumption.id` (a UUID) is stored as a field on the doc and is distinct from the doc ID. The fixed-id-equals-idempotency-key relationship is what defends correctness; the UUID field is for cross-references that don't want to leak idempotency keys.

`findPaymentByProviderIntentId(providerIntentId)` runs a `where('providerIntentId', '==', x).limit(1)` query. Single-field equality queries are auto-indexed by Firestore, so no entry in `firestore.indexes.json` is needed.

### `FirestoreDeliveryStore`

| Collection | Document ID | Purpose |
| --- | --- | --- |
| `delivery_outbound` | UUID | One doc per outbound webhook delivery. State machine: `pending → in_flight → succeeded \| failed \| dead` (and back to `pending` on retry). |
| `delivery_outbound_idempotency` | caller-supplied idempotency key | Pointer doc (`{deliveryId}`) — collision on `tx.create()` is what makes `enqueueDelivery` idempotent, mirroring the consumption-ledger pattern above. |
| `delivery_inbound` | `{source}__{eventId}` | Inbound event-id dedup for `recordInboundEvent` (e.g. Stripe's `event.id` after a webhook retry). Collision on `tx.create()` ⇒ `{recorded: false, firstSeenAt}`. |

The outbound delivery's `event` field (the `WebhookEvent` payload the host wants to deliver) is stored as a raw JSON string in `eventJson` plus a separate scalar `eventType` field for filtering. Storing the payload as a string preserves host bytes — Firestore's structured types (Timestamps, nested map promotion, undefined→null) would otherwise mangle opaque event payloads on round trip.

## Composite indexes

Composite indexes required for the queries these adapters issue, captured in [`firestore.indexes.json`](firestore.indexes.json):

| Collection | Fields | Used by |
| --- | --- | --- |
| `entitlements` | `(accountId ASC, status ASC, scope.service ASC)` | `listEntitlements` with `status` and `service` filters. The same index serves `in`-status and service-only queries. |
| `consumptions` | `(accountId ASC, occurredAt ASC)` | `listConsumptions(accountId, since?)` with chronological ordering. |
| `delivery_outbound` | `(status ASC, scheduledAt ASC)` | `claimDueDeliveries` — the pending-and-due head-of-queue query. |
| `delivery_outbound` | `(status ASC, claimedAt ASC)` | `claimDueDeliveries` — the stale-claim-recovery query (in_flight rows whose `claimedAt` is older than `claimTtlMs`). |
| `delivery_outbound` | `(status ASC, createdAt ASC)` | `listDeliveries` with a status filter. |
| `delivery_outbound` | `(eventType ASC, createdAt ASC)` | `listDeliveries` with an eventType filter. |

Deploy with:

```bash
firebase deploy --only firestore:indexes
```

The Firestore emulator does not enforce indexes — local tests need no deployment step. Production traffic will return `FAILED_PRECONDITION` until the indexes finish building.

If you add a `types` filter to `listEntitlements` that the suite doesn't currently exercise, Firestore will surface a console link to create the additional index on first use.

## BigInt serialization (and the implied $90T ceiling)

Stipend's `Money` type is `{cents: bigint, currency: string}`. Firestore has no native BigInt type — every numeric scalar is a 64-bit float on the wire and surfaces as a JavaScript `number` through the Admin SDK.

This adapter **encodes monetary amounts as Firestore numbers**, converting to/from `bigint` at the SDK boundary. The implied ceiling is `Number.MAX_SAFE_INTEGER` (≈ 9.0 × 10¹⁵ cents ≈ **$90 trillion**) per amount.

This is a deliberate choice, not a workaround:

1. **The receipt signing payload already has this ceiling.** `stipend/src/signing.ts`'s `bigintToSafeNumber` rejects amounts above `Number.MAX_SAFE_INTEGER` so canonical JSON stays interoperable. The store enforcing a tighter ceiling than the signer would be strictly worse — corrupt receipts could be stored but never signed. Matching the signer's ceiling means the store and the signer fail in the same place for the same reason.
2. **Firestore Console and query operators stay useful.** Amounts are inspectable in the Firebase console and can participate in `where('amount.cents', '>', x)` queries without round-tripping through a string codec.
3. **No realistic deployment hits this limit.** A balance of $90 trillion is not a use case Stipend is engineering for. If it ever becomes one, both the signing payload and the store would need a coordinated change (a `signature_version` bump per TDD §10).

The decoder is strict: a non-integer or out-of-range value in a stored doc raises `InternalError`, so corruption surfaces immediately rather than silently rounding.

## Security Rules

The Admin SDK bypasses Security Rules entirely, so the recommended posture is **deny everything to non-admin callers**. A baseline that does exactly that lives in [`firestore.rules.example`](firestore.rules.example), with a commented-out carve-out showing how to open a narrow read path for end-user UIs that need to display receipts.

Copy the example to `firestore.rules`, adapt to your auth model, reference it from `firebase.json`, and deploy with:

```bash
firebase deploy --only firestore:rules
```

If your deployment has *no* client SDK at all (e.g. you only ever talk to Firestore through the Stipend service), deploy the unmodified baseline — the rules then function as defense-in-depth against a misconfigured client library or an accidentally exposed REST endpoint.

## Atomicity

### `FirestoreStore.checkAndConsume`

Runs inside a single `runTransaction` callback. Reads come first (consumption-by-id, then entitlement-by-id), validation happens, then writes (entitlement update + consumption `tx.create()`) commit atomically. Firestore handles write contention by retrying the callback body; the consumption doc's collision-on-second-writer guarantee is what prevents double-spend on logical retries with the same idempotency key.

The `no_longer_valid` result is reserved for **semantic post-lock failures**: entitlement missing or revoked, scope mismatch, expired subscription, insufficient balance, exhausted quota. Firestore's own transient retry of the transaction body never surfaces as `no_longer_valid`.

Lazy quota reset happens *inside* the transaction: if `resetAt < now`, the same write that decrements `remaining` also advances `resetAt` to the next period boundary.

### `FirestoreDeliveryStore.enqueueDelivery`

Single `runTransaction`. Reads the idempotency pointer doc; if it exists, returns the existing delivery (`created: false`). Otherwise `tx.create()`s the delivery doc and the pointer doc atomically. Two concurrent calls with the same key race; the loser hits `ALREADY_EXISTS`, Firestore retries the body, the retry reads the pointer doc from step one, and the loser returns `created: false`.

### `FirestoreDeliveryStore.claimDueDeliveries`

Two head-of-queue queries (`status='pending' && scheduledAt ≤ now` and `status='in_flight' && claimedAt ≤ now − claimTtlMs`) gather candidate IDs. Each candidate is then claimed in its own small transaction — one transaction per row, not one big batch. This means a single contested row (a peer worker grabbed it first) doesn't poison the rest of the batch; the loser for that row continues with the next candidate. Workers should tune `claimTtlMs` to (worst-case HTTP timeout + a safety margin); the default is 60 s.

### `FirestoreDeliveryStore.recordInboundEvent`

Single `runTransaction`. Reads the `{source}__{eventId}` doc; if it exists, returns `{recorded: false, firstSeenAt}`. Otherwise `tx.create()`s the marker. Same collision-on-second-writer guarantee as `enqueueDelivery`.

## Local development

Run both conformance suites against the Firestore emulator:

```bash
# In packages/store-firestore/
firebase emulators:start --only firestore --project stipend-test

# In another shell, from the repo root:
FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 pnpm test
```

Without `FIRESTORE_EMULATOR_HOST`, both Firestore conformance suites skip with a clear message — so `pnpm test` and CI runs that don't have the emulator stay green. CI integration of the emulator is a follow-up tracked in the [v0.1.0 alpha build journal](https://github.com/third-eye-technologies/stipend-mcp/blob/main/docs/history/v0.1.0-alpha-build-journal.md).

The Firestore-backed conformance runs are slow (~7 minutes total) — the property tests for `EntitlementStore.checkAndConsume` and `DeliveryStore.claimDueDeliveries` each fan out 4–20 concurrent transactions per iteration, and Firestore's optimistic concurrency means many retry several times under contention. The same suite against `@stipend-mcp/store-memory` completes in well under a second.
