Chapter 27 · Production Capstone: Design, Secure, Scale, Search, Recover, and Operate a Firestore Application
Implement Documents / Indexes / Queries / Listeners / Transactions, Typed SDK Access, Security Rules, and Trusted Backend Paths
Implement AtlasMart Core documents, indexes, typed access, Rules, listeners and an idempotent trusted checkout path with separate client/server authorization.
1. AtlasMart implementation: one request, two trust paths
The capstone becomes real when the same application clearly
separates what an untrusted browser/mobile client may do from
what only a trusted backend may do. A customer can browse
products, edit their own cart and listen to their own order. The
customer cannot decrement inventory, set an order to
paid, rewrite prices, or use an Admin SDK
credential. Checkout is a backend business operation
authenticated with the user identity, authorized by application
logic, and executed with server IAM.
- Implement stable document contracts and indexes for AtlasMart access patterns.
- Use typed converters/validators so schemaVersion and money/identity fields cross SDK boundaries predictably.
- Make Rules queries provably compatible rather than filtering unauthorized results client-side.
- Implement checkout as an idempotent trusted transaction with no irreversible side effects inside the retryable callback.
- Test listeners, transaction conflicts, Rules denials and server-path authorization separately.
Use the Emulator Suite, a Firebase demo project, or an isolated test project for destructive, security-sensitive, billing-sensitive, migration, backup/restore, or write-heavy exercises unless the lesson explicitly marks managed verification as required. Treat shown output as expected evidence unless it is explicitly identified as captured output, and re-check current Firebase/Google Cloud edition, mode, quota, pricing, and security documentation before production execution.
AtlasMart keeps project ID
demo-atlasmart-firestore, Standard edition /
Native mode / Core operations, database
(default), Node.js 22+, Firebase CLI course
baseline 15.30.0, Firebase JavaScript SDK
12.19.0, Firebase Admin Node SDK
14.4.0,
@firebase/rules-unit-testing 5.0.2, Firestore
emulator 127.0.0.1:8080, Auth emulator
127.0.0.1:9099, and Emulator UI
127.0.0.1:4000. The mandatory capstone uses only
a demo- project and local tooling. Managed
location, production IAM/App Check, real composite/vector
indexes, Query Explain/Insights, billing, quotas, Key
Visualizer, scheduled backups/PITR, CMEK/network controls,
Enterprise/Pipeline, and MongoDB-compatibility validation are
optional managed evidence and are never inferred from emulator
success. Where a managed Standard example needs a concrete
location, this chapter uses us-central1 only as
an explicit example—not a universal recommendation.
2. Source-controlled runtime configuration
{ "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json", "edition": "standard" }, "emulators": { "auth": { "host": "127.0.0.1", "port": 9099 }, "firestore": { "host": "127.0.0.1", "port": 8080 }, "ui": { "host": "127.0.0.1", "port": 4000 } }}
{ "indexes": [ { "collectionGroup": "products", "queryScope": "COLLECTION", "fields": [ { "fieldPath": "active", "order": "ASCENDING" }, { "fieldPath": "categoryId", "order": "ASCENDING" }, { "fieldPath": "updatedAt", "order": "DESCENDING" } ] }, { "collectionGroup": "orders", "queryScope": "COLLECTION", "fields": [ { "fieldPath": "ownerUid", "order": "ASCENDING" }, { "fieldPath": "createdAt", "order": "DESCENDING" } ] } ], "fieldOverrides": [ { "collectionGroup": "events", "fieldPath": "expireAt", "indexes": [] } ]}
rules_version = '2';service cloud.firestore { match /databases/{database}/documents { function signedIn() { return request.auth != null; } function tenant() { return request.auth.token.tenantId; } function sameTenant(t) { return signedIn() && tenant() == t; } function owns(uid) { return signedIn() && request.auth.uid == uid; } match /products/{productId} { allow read: if resource.data.active == true && resource.data.visibility == "public"; allow write: if false; // trusted backend only } match /carts/{uid} { allow read, create, update: if owns(uid) && request.resource.data.ownerUid == uid && request.resource.data.schemaVersion in [1, 2]; allow delete: if owns(uid); match /items/{itemId} { allow read, write: if owns(uid) && request.resource.data.ownerUid == uid; } } match /orders/{orderId} { allow read: if signedIn() && resource.data.ownerUid == request.auth.uid; allow write: if false; // checkout/status writes use trusted backend + IAM } }}
The index file is a Standard Native contract. The emulator does not track compound indexes, so local query success is not proof that the composite index exists in production. The Rules file protects mobile/web client requests. The trusted backend uses the Admin/server library and therefore requires IAM plus application-layer authorization; it does not become subject to these Rules merely because it is acting for a user.
3. Typed SDK access: validate at the boundary
export function decodeProduct(id, data) { if (!data || data.schemaVersion !== 2) throw new Error(`unsupported product ${id}`); if (!Number.isInteger(data.priceCents) || data.priceCents < 0) throw new Error("bad price"); if (typeof data.active !== "boolean") throw new Error("bad active flag"); return Object.freeze({ id, categoryId: String(data.categoryId), name: String(data.name), priceCents: data.priceCents, active: data.active, updatedAt: data.updatedAt });}export function encodeCartItem(item) { if (!Number.isInteger(item.quantity) || item.quantity < 1 || item.quantity > 20) { throw new Error("quantity out of range"); } return { ownerUid: item.ownerUid, productId: item.productId, quantity: item.quantity, observedPriceCents: item.observedPriceCents, updatedAt: item.updatedAt, schemaVersion: 2 };}
A converter/validator is not a substitute for Rules or server validation. It gives the application a typed failure boundary and prevents silent schema drift. An attacker can bypass client code, so authorization and protected-field constraints remain on the trusted boundary.
4. Query contract: Rules are not filters
import { collection, query, where, orderBy, limit, getDocs } from "firebase/firestore";const q = query( collection(db, "products"), where("active", "==", true), where("visibility", "==", "public"), where("categoryId", "==", "drinkware"), orderBy("updatedAt", "desc"), limit(20));const snap = await getDocs(q);
The query must be compatible with both Rules and indexes. A client query that fetches all products and then filters hidden rows in JavaScript is not merely inefficient; it is denied when the possible result set contains documents the Rules do not authorize.
The emulator executes valid queries without tracking compound indexes. A managed staging database must prove the required composite index is deployed and ready before enabling this query in production.
5. Realtime order status: cache metadata is product state
import { doc, onSnapshot } from "firebase/firestore";const stop = onSnapshot( doc(db, "orders", orderId), { includeMetadataChanges: true }, snap => { if (!snap.exists()) return renderMissing(); const m = snap.metadata; renderOrder({ ...snap.data(), source: m.fromCache ? "cache" : "server", pendingLocalWrites: m.hasPendingWrites }); });// call stop() when the screen unmounts
The listener is not a free push channel. Its initial result and later changes follow current Firestore billing semantics. The UI must also distinguish cache/server and pending-write state instead of presenting cached data as fresh server confirmation.
6. Trusted checkout: user identity enters; privileged state change stays on server
process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080";process.env.GCLOUD_PROJECT = "demo-atlasmart-firestore";import { initializeApp } from "firebase-admin/app";import { getAuth } from "firebase-admin/auth";import { getFirestore, FieldValue } from "firebase-admin/firestore";initializeApp({ projectId: "demo-atlasmart-firestore" });const db = getFirestore();export async function checkout(idToken, key, skuId, qty) { const decoded = await getAuth().verifyIdToken(idToken); const uid = decoded.uid; const orderRef = db.collection("orders").doc(`${uid}_${key}`); const inventoryRef = db.collection("inventory").doc(skuId); return db.runTransaction(async tx => { const [existing, inventory] = await Promise.all([ tx.get(orderRef), tx.get(inventoryRef) ]); if (existing.exists) return { orderId: orderRef.id, duplicate: true }; if (!inventory.exists || inventory.get("available") < qty) { throw new Error("OUT_OF_STOCK"); } tx.update(inventoryRef, { available: FieldValue.increment(-qty), reserved: FieldValue.increment(qty), updatedAt: FieldValue.serverTimestamp() }); tx.create(orderRef, { ownerUid: uid, idempotencyKey: key, status: "reserved", skuId, quantity: qty, createdAt: FieldValue.serverTimestamp(), schemaVersion: 2 }); return { orderId: orderRef.id, duplicate: false }; });}
A transaction callback can execute more than once. Therefore sending email, charging a payment provider or publishing an irreversible external event inside the callback is unsafe. Persist the durable order/outbox state atomically; process external side effects idempotently after commit.
7. Failure injection: prove the trust boundary
| Injected mistake | Expected signal | Repair |
|---|---|---|
Client writes orders/o1.status="paid".
|
Rules test denies write. | Status transitions remain backend-only. |
| Client broad-queries products then filters private items locally. | Rules deny query. | Query includes provably safe predicates. |
| Two checkouts race for one remaining SKU. | One transaction succeeds; competing path retries/fails invariant. | Keep inventory/order invariant in transaction and surface retry-safe error. |
| Same idempotency key submitted twice. | Second call returns existing order, no second decrement. | Deterministic order/idempotency record. |
| Transaction callback sends email before commit. | Failure injection demonstrates duplicate email on retry. | Move side effect to durable idempotent post-commit worker. |
8. Rules and server authorization are separate tests
const alice = testEnv.authenticatedContext("alice", { tenantId: "t-red" });const mallory = testEnv.authenticatedContext("mallory", { tenantId: "t-blue" });await assertSucceeds(alice.firestore().doc("carts/alice").get());await assertFails(mallory.firestore().doc("carts/alice").get());await assertFails(alice.firestore().doc("orders/o-1").set({ status: "paid" }));
Separately test the backend authorization function that maps a verified user token to allowed checkout resources. Admin SDK success proves only that the backend identity can reach Firestore; it does not prove the user should be allowed to invoke that business operation.
9. Mandatory lab and verification checklist
-
Start Auth + Firestore emulators with
demo-atlasmart-firestore. - Deploy/load the local Rules and seed product/cart/inventory fixtures.
- Assert public product browse succeeds and broad unsafe query fails Rules.
- Assert Alice can edit her cart and Mallory cannot read it.
- Run two concurrent checkout attempts for one remaining SKU and assert no oversell.
- Replay the winning checkout with the same idempotency key and assert inventory is not decremented twice.
- Attach an order listener, record cache/server metadata and call unsubscribe.
-
Record
VERIFY_MANAGEDfor composite-index readiness, IAM/App Check, production transaction contention and billing evidence.
Client capabilities are bounded by Rules; backend capabilities are bounded by verified user/business authorization plus IAM; the inventory invariant survives contention and replay; no emulator result is mislabeled as production capacity/index evidence.
Production judgment and bridge
The core application is now intentionally boring: documents, query contracts, a small number of indexes, a clear listener, and one authoritative transaction boundary. Lesson 3 asks whether aggregation, semantic vectors, Enterprise Pipeline or MongoDB compatibility add enough value to justify new operational surfaces.
Knowledge check
- Why must the product query repeat security-relevant constraints?
- Why does Admin SDK access not prove end-user authorization?
- Why is email unsafe inside a transaction callback?
- What does
fromCachetell the UI? - Why must composite-index readiness be checked outside the emulator?
Review the answers
1. Rules evaluate a query against its possible result set; they are not post-query filters.
2. Server libraries bypass Firestore Rules and use IAM, so the application must authorize the verified user action itself.
3. The transaction can retry, so the callback can execute more than once.
4. The snapshot was served from local cache rather than confirmed current server state.
5. The emulator does not track compound indexes and can run a query that production would reject until the index exists.
Summary and next step
This lesson established the working contract for Implement Documents/Indexes/Queries/Listeners/Transactions, Typed SDK Access, Security Rules, and Trusted Backend Paths. Keep its edition/mode assumptions, trust boundary, verification evidence, and operational constraints explicit when reusing the pattern.
Next, continue to Add Aggregation, Vector Retrieval, Enterprise Pipeline or MongoDB Compatibility Only Where Requirements Justify Them.
Authoritative references
- Firebase: Cloud Firestore documentation — Standard Native/Core application semantics.
- Firebase: Security Rules conditions — Rules are not filters and server libraries bypass Rules in favor of IAM/ADC.
- Firebase: Connect to the Cloud Firestore emulator — demo projects and emulator/production differences.
- Firebase: Transactions and batched writes — retries, atomicity and offline constraints.
- Firebase: Firestore best practices — hotspot avoidance and gradual traffic ramping.
- Firebase: Firestore pricing — document/index-entry/listener/aggregation billing in Standard.
- Firebase: Vector search — vector indexes, dimensions, query limits and supported server SDKs.
- Google Cloud: Firestore release notes — current Enterprise/Pipeline launch status and product changes.
- Google Cloud: Core and Pipeline query interfaces — Standard/Enterprise indexing and query-interface differences.
- Google Cloud: Firestore with MongoDB compatibility overview — serverless compatibility surface, not MongoDB itself.
- Google Cloud: Firestore backups and restore — managed recovery mechanisms.
- Google Cloud: Point-in-time recovery — historical recovery window and managed semantics.