Firebase Firestore skill for Codex
Predictable reads, tested rules, versioned indexes. Codex models Firestore around each screen's queries and read count, splits documents by access rule, and keeps composite indexes in firestore.indexes.json instead of console clicks. Security rules validate shape and changed keys, deny by default and get emulator tests for every allowed and denied case. Listeners, counters and sequential fields are checked for bill spikes and write hotspots.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- firebase-firestore Installs to ~/.agents/skills/firebase-firestore/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install firebase-firestore The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project firebase-firestore Installs to .agents/skills/firebase-firestore/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/firebase-firestore.zipunzip -o firebase-firestore.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) firebase-firestore PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $firebase-firestore, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Models Cloud Firestore data from screens and queries - documents, subcollections, collection group queries, denormalized fields and counters - writes and unit-tests security rules with the Emulator Suite and @firebase/rules-unit-testing, manages composite indexes in firestore.indexes.json, and controls cost by counting document reads from listeners, pagination and aggregation queries. Covers the modular Web SDK, Admin SDK batched writes and transactions, and hotspot limits. Use when designing Firestore collections, writing firestore.rules, a query fails for a missing index, the Firestore bill or read count jumps, or writes are throttled.
Use it when
- Designing Firestore collections and subcollections
- Writing or reviewing firestore.rules
- A query needs a composite index or fails with an index error
- Firestore costs or write contention increase
Use something else when
- Sign-in providers and Firebase Authentication setup: use firebase-auth
- Hosting rewrites and Cloud Functions deployment: use firebase-hosting
How to check the result
Emulator rules tests pass for allowed and denied cases on every collection, each screen's reads match the query table, and all code queries have indexes in firestore.indexes.json.
The mistake it prevents
Treating security rules as filters, so a query without the matching where clause is rejected even though each returned document would be allowed.
Done well means: each screen loads with a small, predictable number of document reads; security rules deny by default and are covered by emulator tests for every allowed and denied case; composite indexes live in version control; and no single document or index range takes more writes than Firestore can sustain.
Inspect first
- Read
AGENTS.md; openfirebase.json,.firebaserc,firestore.rulesandfirestore.indexes.json. - Identify SDKs: Web modular SDK (
firebase/firestore), Admin SDK (firebase-admin), mobile SDKs. The Admin SDK bypasses security rules entirely. - Find listeners (
onSnapshot) and queries per screen; they drive cost. - Confirm the Emulator Suite runs locally (
firebase emulators:start --only firestore); it needs Java and the Firebase CLI.
Model from screens
Write the screen/query table first:
| Screen | Query | Docs read per load | Live? |
|---|---|---|---|
| Project list | orgs/{orgId}/projects where archived == false orderBy updatedAt desc limit 20 | 20 | yes |
| Project page | orgs/{orgId}/projects/{id} + .../tasks limit 50 | 51 | yes |
| My tasks across projects | collection group tasks where assigneeId == uid | <= 50 | no |
Rules:
- A document is the unit of read billing, security and contention. Group fields read
together into one document; split fields with different access rules into different
documents (for example
users/{uid}public profile vsusers/{uid}/private/settings). - Subcollections for unbounded children (tasks of a project). Arrays only for small, bounded lists (tags, up to a few dozen).
- Documents max 1 MiB; keep them small since every read downloads the whole document.
- Denormalize what list screens display (
projectNameon a task) and decide how copies are updated (Cloud Function trigger or batched write at edit time). - Collection group queries (
collectionGroup(db, "tasks")) query everytaskssubcollection; they need a collection-group index and rules written for the{path=**}/tasks/{taskId}form. - Store ids of related documents, not references to fetch one by one; batch with
inqueries (up to 30 values) or restructure.
Queries and indexes
import { collection, query, where, orderBy, limit, startAfter, getDocs, getCountFromServer } from "firebase/firestore";
const q = query(
collection(db, "orgs", orgId, "projects"),
where("archived", "==", false),
orderBy("updatedAt", "desc"),
limit(20),
);
const page = await getDocs(q);
const next = query(q, startAfter(page.docs[page.docs.length - 1]));
const total = await getCountFromServer(q); // aggregation: billed per batch of index entries, not per doc
- Single-field indexes are automatic. Equality plus
orderByon another field, or range filters with sorting, need a composite index. The error message contains a console link; instead of clicking it, add the index tofirestore.indexes.jsonand deploy, so it is versioned:
{
"indexes": [
{ "collectionGroup": "projects", "queryScope": "COLLECTION",
"fields": [ { "fieldPath": "archived", "order": "ASCENDING" },
{ "fieldPath": "updatedAt", "order": "DESCENDING" } ] },
{ "collectionGroup": "tasks", "queryScope": "COLLECTION_GROUP",
"fields": [ { "fieldPath": "assigneeId", "order": "ASCENDING" },
{ "fieldPath": "dueAt", "order": "ASCENDING" } ] }
],
"fieldOverrides": [
{ "collectionGroup": "events", "fieldPath": "payload",
"indexes": [] }
]
}
firebase firestore:indexesprints the deployed index config, to reconcile with the file.- Exempt large maps, long strings and fields never queried (
fieldOverrideswith emptyindexes) to cut storage and write cost. - Paginate with cursors (
startAfter), neveroffset: skipped documents are still billed. in,not-inandarray-contains-anyaccept up to 30 values;orqueries are limited by the number of disjunctions they expand into.
Writes, transactions, hotspots
// Admin SDK: atomic multi-document write
const batch = adminDb.batch();
batch.update(projectRef, { taskCount: FieldValue.increment(1), updatedAt: FieldValue.serverTimestamp() });
batch.set(taskRef, task);
await batch.commit();
- Use
FieldValue.incrementfor counters instead of read-then-write. - Transactions read before they write and retry on contention; keep them small. Client SDK transactions fail offline.
- A single document sustains roughly one write per second. High-rate counters need a distributed counter (N shard documents summed on read) or aggregation queries.
- Sequential values in an indexed field (timestamps, incrementing ids) concentrate writes in one index range. For high write rates, exempt the field from indexing or add a random prefix/shard field. Ramp new collections gradually (start near 500 ops/s and grow about 50% every 5 minutes).
- Bulk deletes or backfills from scripts use
BulkWriterin the Admin SDK; run against production only with explicit user confirmation, after testing on the emulator.
Security rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function signedIn() { return request.auth != null; }
function isMember(orgId) {
return signedIn() && exists(/databases/$(database)/documents/orgs/$(orgId)/members/$(request.auth.uid));
}
match /orgs/{orgId}/projects/{projectId} {
allow read: if isMember(orgId);
allow create: if isMember(orgId)
&& request.resource.data.keys().hasOnly(['name', 'archived', 'ownerId', 'updatedAt'])
&& request.resource.data.ownerId == request.auth.uid
&& request.resource.data.name is string && request.resource.data.name.size() <= 120;
allow update: if isMember(orgId)
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['name', 'archived', 'updatedAt']);
allow delete: if false;
}
}
}
- Rules are not filters: a query is allowed only if the rules can prove every possible
result is readable. Queries must include the constraints the rules check (for example
where("ownerId", "==", uid)). - Validate shape on create and restrict changed keys on update; otherwise clients can write any field, including roles.
get()/exists()calls count as billed reads and are capped per request (10 for single document requests, 20 for multi-document reads, transactions and batches).- Store roles in documents the client cannot write, or in custom claims set by the Admin SDK.
- Deny by default: no
allow read, write: if true, and no test-mode rules with expiry dates left in production.
For a catalog of rule patterns (role checks, owner-only fields, immutable fields, rate-limiting hints, collection group rules) and a full emulator test harness, read references/security-rules-patterns.md when writing or reviewing rules.
Cost control
- Every document returned is a read, including each document delivered to a listener when it changes and the initial snapshot. Listeners on large collections are the usual bill spike.
- Limit listeners to what is on screen; detach them on unmount.
- Offline persistence serves repeat loads from cache; resumed listeners within 30 minutes are billed only for changed documents.
- Use aggregation queries (
count,sum,average) instead of downloading documents to count. - Check usage in the console (Usage tab, Key Visualizer for hotspots) and set a budget alert.
Deploy
firebase emulators:exec --only firestore "npm test" # rules + integration tests
firebase deploy --only firestore:rules,firestore:indexes # confirm with the user first
Deploying rules takes effect within about a minute for new queries and can lock out clients; deploy only after emulator tests pass, and with user confirmation. Deploying needs network and Firebase credentials.
Verification
- Emulator rules tests cover allowed and denied cases for each collection (signed out, other user, member, owner, bad shape, forbidden field change).
- Each screen’s read count matches the query table (Emulator UI request log, or console usage).
- No query in the code requires an index missing from
firestore.indexes.json.
Output
**Collections:** <path: fields, who writes, who reads>
**Queries:** <screen -> query -> index -> reads per load>
**Rules:** <summary per collection>, tests: <n allowed / n denied cases>
**Indexes:** firestore.indexes.json changes, exemptions
**Cost/hotspot notes:** <listeners, counters, sharding> What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Firebase Firestore"
short_description: "Predictable reads, tested rules, versioned indexes"
default_prompt: "$firebase-firestore Design Firestore collections for orgs, projects and tasks with per-org access" references/security-rules-patterns.md
# Firestore security rules patterns and tests
Use these when writing or reviewing `firestore.rules`. Every pattern needs a matching
allowed and denied test in the emulator.
## Helper functions
```text
function signedIn() { return request.auth != null; }
function uid() { return request.auth.uid; }
function isOwner(data) { return signedIn() && data.ownerId == uid(); }
function hasRole(orgId, roles) {
return signedIn()
&& get(/databases/$(database)/documents/orgs/$(orgId)/members/$(uid())).data.role in roles;
}
function claimAdmin() { return signedIn() && request.auth.token.admin == true; }
function unchanged(field) {
return request.resource.data.get(field, null) == resource.data.get(field, null);
}
```
`get()` on the same document path is cached within one request evaluation, but each
distinct path counts toward the per-request limit and is billed as a read.
## Pattern catalog
| Need | Rule fragment |
|---|---|
| Owner-only document | `allow read, write: if isOwner(resource.data);` plus create rule using `request.resource.data` |
| Create sets owner to caller | `allow create: if request.resource.data.ownerId == uid();` |
| Only listed fields on create | `request.resource.data.keys().hasOnly([...]) && request.resource.data.keys().hasAll([...required])` |
| Only some fields change on update | `request.resource.data.diff(resource.data).affectedKeys().hasOnly(['title', 'body'])` |
| Immutable field | `unchanged('createdAt') && unchanged('ownerId')` |
| Server timestamp enforced | `request.resource.data.updatedAt == request.time` |
| Type and size checks | `data.title is string && data.title.size() >= 1 && data.title.size() <= 200` |
| Enum value | `request.resource.data.status in ['draft', 'published']` |
| Role from membership doc | `hasRole(orgId, ['admin', 'editor'])` |
| Role from custom claim | `request.auth.token.role == 'admin'` (set via Admin SDK `setCustomUserClaims`) |
| Email verified | `request.auth.token.email_verified == true` |
| Read only published, or own drafts | `resource.data.status == 'published' \|\| isOwner(resource.data)` (queries must filter the same way) |
| Collection group read | `match /{path=**}/tasks/{taskId} { allow read: if resource.data.assigneeId == uid(); }` |
| Limit list size per query | `allow list: if request.query.limit <= 50;` |
| Separate get vs list | `allow get: if ...; allow list: if ...;` |
| Deny everything else | Omit matches; unmatched paths are denied |
Notes:
- There is no range operator; write explicit `>=`/`<=` comparisons.
- `resource` is the document before the write (null on create); `request.resource` is the
document as it would be after the write, including unchanged fields.
- Rules cannot rate-limit reliably. Use App Check to restrict clients to your apps, and
Cloud Functions or a backend for abuse-sensitive operations.
- Role documents (`members/{uid}`) must themselves be writable only by admins or the Admin SDK.
## Test harness (Node, Vitest or Jest)
```ts
import { initializeTestEnvironment, assertFails, assertSucceeds, RulesTestEnvironment } from "@firebase/rules-unit-testing";
import { readFileSync } from "node:fs";
import { doc, getDoc, setDoc, updateDoc, collection, query, where, getDocs } from "firebase/firestore";
let env: RulesTestEnvironment;
beforeAll(async () => {
env = await initializeTestEnvironment({
projectId: "demo-rules-test", // demo- prefix: never touches a real project
firestore: { rules: readFileSync("firestore.rules", "utf8") },
});
});
afterAll(() => env.cleanup());
beforeEach(async () => {
await env.clearFirestore();
await env.withSecurityRulesDisabled(async (ctx) => {
const db = ctx.firestore();
await setDoc(doc(db, "orgs/o1/members/alice"), { role: "editor" });
await setDoc(doc(db, "orgs/o1/projects/p1"), { name: "A", ownerId: "alice", archived: false });
});
});
test("member reads project", async () => {
const db = env.authenticatedContext("alice").firestore();
await assertSucceeds(getDoc(doc(db, "orgs/o1/projects/p1")));
});
test("non-member cannot read", async () => {
const db = env.authenticatedContext("mallory").firestore();
await assertFails(getDoc(doc(db, "orgs/o1/projects/p1")));
});
test("signed-out cannot read", async () => {
const db = env.unauthenticatedContext().firestore();
await assertFails(getDoc(doc(db, "orgs/o1/projects/p1")));
});
test("member cannot change owner", async () => {
const db = env.authenticatedContext("alice").firestore();
await assertFails(updateDoc(doc(db, "orgs/o1/projects/p1"), { ownerId: "mallory" }));
});
test("non-member list query is rejected", async () => {
const db = env.authenticatedContext("bob").firestore();
await assertFails(getDocs(query(collection(db, "orgs/o1/projects"), where("archived", "==", false))));
});
```
Run under the emulator:
```bash
firebase emulators:exec --only firestore "npx vitest run tests/rules"
```
## Review checklist
- [ ] No `if true` on any path holding user data; no expired "test mode" rules.
- [ ] Every `create` validates keys, types and ownership; every `update` restricts changed keys.
- [ ] Role and membership documents are not client-writable.
- [ ] Collection group queries have matching `{path=**}` rules.
- [ ] `get()`/`exists()` count per request stays under the limit for batch writes.
- [ ] Tests cover signed-out, other user, member, owner, malformed data and forbidden field changes.
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.