diff --git a/.agents/skills/firebase-firestore/SKILL.md b/.agents/skills/firebase-firestore/SKILL.md new file mode 100644 index 0000000..d6c809a --- /dev/null +++ b/.agents/skills/firebase-firestore/SKILL.md @@ -0,0 +1,108 @@ +--- +name: firebase-firestore +description: >- + Sets up, manages, queries, and configures Cloud Firestore databases (Standard/Enterprise edition), including data modeling, security rules, indexes, and SDK integrations (Web, Python, iOS, Android, Flutter). Use when creating/listing Firestore databases, defining data models/indexes, writing SDK queries, or integrating Firestore SDKs. For authoring or modifying Firestore Security Rules (firestore.rules), delegate to the firestore-rules-author subagent if subagent delegation is available, or use firestore-rules-creation otherwise. Don't use for Firebase Hosting, Data Connect, Auth, Storage/GCS, Crashlytics, Functions, or BigQuery. +compatibility: This skill is best used with the Firebase CLI, but does not require it. Firebase CLI can be accessed through `npx -y firebase-tools@latest`. +metadata: + author: Google LLC + category: Databases +--- + +# Cloud Firestore Database and Operations + +> [!IMPORTANT] **Security Rules Authoring (`firestore.rules`)** Whenever your +> task requires creating, authoring, or modifying `firestore.rules`: +> +> - **If subagent delegation AND the `firestore-rules-author` subagent are +> available**: delegate authoring `firestore.rules` to the +> `firestore-rules-author` subagent rather than writing `firestore.rules` +> directly in the main agent. +> - **If subagent delegation is unavailable** (e.g., subagents not enabled in +> the IDE) **OR `firestore-rules-author` is not installed**: read and follow +> the `firestore-rules-creation` skill to write `firestore.rules` directly. + +Before setting up dependencies, writing data models, or configuring security +rules, you MUST always identify the Firestore instance edition. + +## 1. Instance Selection and Edition Detection + +Run the following command to list current Firestore databases: + +```bash +npx -y firebase-tools@latest firestore:databases:list +``` + +### A. Instance Found + +1. For each database found, inspect its edition and details: + + ```bash + npx -y firebase-tools@latest firestore:databases:get + ``` + +1. Ask the user which database instance they wish to target or if they would + prefer to create a new instance. + +1. Once the target instance is established: + + - If the **`edition`** is `STANDARD`, follow the guides under + `references/standard/`. + - If the **`edition`** is `ENTERPRISE` or native mode, follow the guides + under `references/enterprise/`. + +### B. No Instance Found (or New Requested) + +If no databases exist or the user requests a new one, default to provisioning an +**Enterprise** edition database and ask the user what location to use. Run +`npx -y firebase-tools@latest firestore:locations` to get the list of options. +Suggest colocating with other resources if applicable. + +Once the location is determined, create the database: + +```bash +npx -y firebase-tools@latest firestore:databases:create --edition="enterprise" --location="" +``` + +Proceed with using the guides under `references/enterprise/`. + +______________________________________________________________________ + +## 2. Specialized Guides + +Based on the identified or created instance edition, open and read the +corresponding reference guides: + +### Standard Edition (`references/standard/`) + +- **Provisioning**: Read [provisioning.md](references/standard/provisioning.md) +- **Security Rules**: See the `firestore-rules-creation` skill +- **SDK Usage**: Read [web_sdk_usage.md](references/standard/web_sdk_usage.md), + [android_sdk_usage.md](references/standard/android_sdk_usage.md), + [ios_setup.md](references/standard/ios_setup.md), or + [flutter_setup.md](references/standard/flutter_setup.md) +- **Indexes**: Read [indexes.md](references/standard/indexes.md) + +### Enterprise Edition / Native Mode (`references/enterprise/`) + +- **Provisioning**: Read + [provisioning.md](references/enterprise/provisioning.md) + +- **Data Model**: Read [data_model.md](references/enterprise/data_model.md) + +- **Security Rules**: See the `firestore-rules-creation` skill + +- **SDK Usage**: + + > [!CRITICAL] **Mandatory Reference Reading** Before writing or modifying any + > application code for Firestore Enterprise Edition, you **MUST** read at + > least one of the relevant reference documents below for the target + > platform/language to understand specific architectural requirements and + > pipeline initialization patterns. + + Read [web_sdk_usage.md](references/enterprise/web_sdk_usage.md), + [python_sdk_usage.md](references/enterprise/python_sdk_usage.md), + [android_sdk_usage.md](references/enterprise/android_sdk_usage.md), + [ios_setup.md](references/enterprise/ios_setup.md), or + [flutter_setup.md](references/enterprise/flutter_setup.md) + +- **Indexes**: Read [indexes.md](references/enterprise/indexes.md) diff --git a/.agents/skills/firebase-firestore/references/enterprise/android_sdk_usage.md b/.agents/skills/firebase-firestore/references/enterprise/android_sdk_usage.md new file mode 100644 index 0000000..53bb3c4 --- /dev/null +++ b/.agents/skills/firebase-firestore/references/enterprise/android_sdk_usage.md @@ -0,0 +1,257 @@ +# Cloud Firestore (Enterprise edition) - Android Setup Guide (Kotlin) + +This guide describes the SDK setup and basic usage patterns for Cloud Firestore +(Enterprise edition in Native mode) in an Android app using Kotlin DSL +(`build.gradle.kts`) and Kotlin code. + +## Prerequisites + +IMPORTANT: Before specifically working with Cloud Firestore, make sure to use +the skill and reference `firebase_basics/references/android_setup` to ensure the +following is done. + +- The Firebase CLI is available and authenticated. +- An Android project exists and is registered with a Firebase Project. +- The Android project has a Firebase config file (`google-services.json`) and + the Google services Gradle plugin (`google-services`). + +______________________________________________________________________ + +## 1. Provision Firestore + +Follow the instructions in +`firebase_firestore/references/enterprise/provisioning` to do the following: + +- Provision a Firestore instance (Enterprise edition in Native mode) +- Create or append a `firebase.json` file +- Create a `firestore.rules` file +- Create a `firestore.indexes.json` file +- Deploy database, rules, and indexes +- Run Firestore locally for development and testing + +______________________________________________________________________ + +## 2. Add dependencies to Gradle build files + +> [!IMPORTANT] **[AGENT] RESOLVING THE SDK VERSION DETERMINISTICALLY** Never +> guess or hardcode a memorized out-of-date SDK or plugin version. Before adding +> dependencies, you MUST query the latest available versions directly from the +> Google Maven Repository: +> +> ```bash +> # Find the latest Firebase Android BoM version +> curl -s https://dl.google.com/dl/android/maven2/com/google/firebase/firebase-bom/maven-metadata.xml | grep -oE '[^<]+' | cut -d'>' -f2 +> +> # Find the latest firebase-firestore version +> curl -s https://dl.google.com/dl/android/maven2/com/google/firebase/firebase-firestore/maven-metadata.xml | grep -oE '[^<]+' | cut -d'>' -f2 +> ``` +> +> If the dependency is already declared, fetch the exact resolved version +> currently active in the workspace's build graph: +> +> ```bash +> ./gradlew -q :app:dependencyInsight --dependency firebase-firestore --configuration releaseRuntimeClasspath +> ``` + +In the **module (app-level)** `build.gradle.kts` (usually +`//build.gradle.kts`), add the dependency for Cloud +Firestore: + +```kotlin +dependencies { + // [AGENT] Fetch the latest resolved version using the Gradle command above + implementation(platform("com.google.firebase:firebase-bom:")) + + // Add the dependency for the Cloud Firestore library + // When using the BoM, don't specify versions in Firebase library dependencies + implementation("com.google.firebase:firebase-firestore") +} +``` + +______________________________________________________________________ + +## 3. Initialize Firestore + +Initialize the `FirebaseFirestore` instance in your `ComponentActivity` (Jetpack +Compose) or standard Activity/Fragment: + +```kotlin +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import com.google.firebase.Firebase +import com.google.firebase.firestore.FirebaseFirestore + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Initialize the Firestore instance + val db = FirebaseFirestore.getInstance("") + + setContent { + MaterialTheme { + Text("Firestore initialized!") + } + } + } +} +``` + +______________________________________________________________________ + +## 4. Decision framework: Mandatory pipeline architecture + +> [!CRITICAL] **MANDATORY PIPELINE ARCHITECTURE (ZERO TOLERANCE)** You are +> strictly forbidden from using standard collection queries (e.g., +> `db.collection(...).where(...)` or client-side mapping loops) as the primary +> implementation for relational joins, aggregations, or search algorithms. +> +> Server-side execution via pipelines is the superior, non-negotiable core +> architecture for Enterprise instances. + +### Strict rules & accountability + +1. **Mandatory Execution Engine**: Every repository lookup requiring related + data mapping, textual relevance scoring, or multi-document counting MUST be + authored natively using the server-side `db.pipeline()` API. +1. **Required Proof of Use**: In your task execution summaries, you MUST + explicitly highlight the exact pipeline stages (`.define()`, `.addFields()`, + `.search()`, `.aggregate()`) integrated into your primary data layer return + paths. Failure to implement pipelines natively constitutes an immediate + violation of instructions. +1. **Check API References**: + - **Locate**: Locate the standard Maven sources JAR (`-sources.jar`) + corresponding to the resolved `firebase-firestore` dependency version + inside your local Gradle or Maven dependency cache. + - **Extract Once**: Extract the documentation files `pipeline.docs.txt` and + `expressions.docs.txt` from the root directory of that `-sources.jar` + archive into a temporary workspace scratch directory of your choice. + - **Read & Reference**: + - **Read** the extracted `pipeline.docs.txt` once fully to understand core + pipeline structure and stage capabilities. + - **Reference** the extracted `expressions.docs.txt` on-demand for specific + function overloads and parameters. + +______________________________________________________________________ + +## 5. Pipeline examples + +### Relational joins pattern + +When querying related data (e.g., articles and their author profiles), perform +the join at the database level via pipeline stages instead of executing multiple +sequential lookups on the client-side. + +- Use `.define()` to bind parameters or document properties as variables. +- Use `.addFields()` and a nested subquery with a matching filter. +- Use `.toScalarExpression()` to convert a nested pipeline subquery to a single + field value. +- Assign variable and field aliases using `.alias(...)` (note: while the Web SDK + uses `.as()`, the Kotlin SDK uses `.alias()` to avoid keyword conflicts with + Kotlin's `as` operator). + +```kotlin +import com.google.firebase.firestore.pipeline.Expression.field +import com.google.firebase.firestore.pipeline.Expression.variable + +// Fetch articles and join the associated author Profile side-by-side +val articlesWithAuthProfile = db.pipeline().collection("articles") + .define(field("authorUid").alias("author_id")) + .addFields( + db.pipeline().collection("users") + .where(field("__name__").documentId().equal(variable("author_id"))) + .select(field("displayName"), field("avatarUrl"), field("handle")) + .toScalarExpression() + .alias("author") + ) +``` + +### Full-text search + +Leverage the database-native `.search()` stage within your pipelines to run +high-performance text query matches on the database level. + +```kotlin +import com.google.firebase.firestore.pipeline.Expression.documentMatches +import com.google.firebase.firestore.pipeline.Expression.score +import com.google.firebase.firestore.pipeline.SearchStage + +// Execute full-text search inside a pipeline, sorted by relevance score descending +val searchPipeline = db.pipeline() + .collection("articles") + .search( + SearchStage.withQuery(documentMatches("machine learning")) + .withSort(score().descending()) + ) + .limit(5) +``` + +______________________________________________________________________ + +## 6. Real-time listener & document operations + +When real-time data sync or transaction-based document mutations are strictly +required by application specifications, write clean operations as shown in this +comprehensive example. + +```kotlin +import android.util.Log +import com.google.firebase.Firebase +import com.google.firebase.firestore.DocumentChange +import com.google.firebase.firestore.firestore + +val db = Firebase.firestore("") +// 1. Add a new document to a collection +val taskData = hashMapOf( + "title" to "Refactor Android SDK Usage Guide", + "status" to "pending" +) + +db.collection("tasks") + .add(taskData) + .addOnSuccessListener { documentReference -> + val taskId = documentReference.id + Log.d("Firestore", "Document added with ID: $taskId") + + // 2. Update specific fields of an existing document without replacing it + db.collection("tasks").document(taskId) + .update("priority", "high") + .addOnSuccessListener { + Log.d("Firestore", "Document successfully updated!") + } + .addOnFailureListener { e -> + Log.w("Firestore", "Error updating document", e) + } + } + .addOnFailureListener { e -> + Log.w("Firestore", "Error adding document", e) + } + +// 3. Establish a real-time listener on a collection query +db.collection("tasks") + .whereEqualTo("status", "pending") + .addSnapshotListener { snapshot, error -> + if (error != null) { + Log.w("Firestore", "Listen failed.", error) + return@addSnapshotListener + } + + snapshot?.documentChanges?.forEach { change -> + val docId = change.document.id + val docData = change.document.data + when (change.type) { + DocumentChange.Type.ADDED -> { + Log.d("Firestore", "Added Task: $docId => $docData") + } + DocumentChange.Type.MODIFIED -> { + Log.d("Firestore", "Updated Task: $docId => $docData") + } + DocumentChange.Type.REMOVED -> { + Log.d("Firestore", "Removed Task: $docId => $docData") + } + } + } + } +``` diff --git a/.agents/skills/firebase-firestore/references/enterprise/data_model.md b/.agents/skills/firebase-firestore/references/enterprise/data_model.md new file mode 100644 index 0000000..3e7f853 --- /dev/null +++ b/.agents/skills/firebase-firestore/references/enterprise/data_model.md @@ -0,0 +1,75 @@ +# Firestore Data Model Reference + +Firestore is a NoSQL, document-oriented database. Unlike a SQL database, there +are no tables or rows. Instead, you store data in **documents**, which are +organized into **collections**. + +## Document Data Model + +Data in Firestore is organized into documents, collections, and subcollections. + +### Documents + +A **document** is a lightweight record that contains fields, which map to +values. Each document is identified by a name. A document can contain complex +nested objects in addition to basic data types like strings, numbers, and +booleans. Documents are limited to a maximum size of 1 MiB. + +Example document (e.g., in a `users` collection): + +```json +{ "first": "Ada", "last": "Lovelace", "born": 1815 } +``` + +### Collections + +Documents live in **collections**, which are containers for your documents. For +example, you could have a `users` collection to contain your various users, each +represented by a document. * Collections can only contain documents. They cannot +directly contain raw fields with values, and they cannot contain other +collections. * Documents within a collection can contain different fields. * You +don't need to "create" or "delete" collections explicitly. After you create the +first document in a collection, the collection exists. If you delete all of the +documents in a collection, the collection no longer exists. + +### Subcollections + +Documents can contain subcollections natively. A subcollection is a collection +associated with a specific document. For example, a user document in the `users` +collection could have a `messages` subcollection containing message documents +exclusively for that user. This creates a powerful hierarchical data structure. + +Data path example: `users/user1/messages/message1` + +## Collection Group Support + +A **collection group** consists of all collections with the same ID. By default, +queries retrieve results from a single collection in your database. Use a +collection group query to retrieve documents from a collection group instead of +from a single collection. + +### Use Cases + +Collection group queries are useful when you want to query across multiple +subcollections that share the same organizational structure. + +For example, imagine an app with a `landmarks` collection where each landmark +has a `reviews` subcollection. If you want to find all 5-star reviews across +*all* landmarks, it would involve checking many separate `reviews` +subcollections. With a collection group, you can perform a single query against +the `reviews` collection group. + +### Examples + +**Standard Query** (Single Collection): Find all 5-star reviews for a specific +landmark. + +```javascript +db.collection('landmarks/golden_gate_bridge/reviews').where('rating', '==', 5) +``` + +**Collection Group Query**: Find all 5-star reviews across *all* landmarks. + +```javascript +db.collectionGroup('reviews').where('rating', '==', 5) +``` diff --git a/.agents/skills/firebase-firestore/references/enterprise/flutter_setup.md b/.agents/skills/firebase-firestore/references/enterprise/flutter_setup.md new file mode 100644 index 0000000..b97a333 --- /dev/null +++ b/.agents/skills/firebase-firestore/references/enterprise/flutter_setup.md @@ -0,0 +1,180 @@ +# Cloud Firestore in Flutter + +This guide covers basic CRUD operations, type-safe data modeling, and real-time +streams when using Cloud Firestore in a Flutter application via the +`cloud_firestore` package. + +## 1. Setup + +Ensure you have added the required dependency: + +```bash +flutter pub add cloud_firestore +``` + +Also, ensure FlutterFire is configured properly for your target platforms. + +______________________________________________________________________ + +## 2. Best Practices: Type-Safe Models + +Instead of passing raw `Map` maps throughout your UI layer, +define a domain model class with `fromFirestore` and `toFirestore` converters to +maintain type safety. + +```dart +import 'package:cloud_firestore/cloud_firestore.dart'; + +class Item { + final String id; + final String name; + final String ownerId; + final DateTime createdAt; + + Item({ + required this.id, + required this.name, + required this.ownerId, + required this.createdAt, + }); + + factory Item.fromFirestore(DocumentSnapshot doc) { + final data = doc.data() as Map? ?? {}; + return Item( + id: doc.id, + name: data['name'] as String? ?? '', + ownerId: data['ownerId'] as String? ?? '', + createdAt: data['createdAt'] is Timestamp + ? (data['createdAt'] as Timestamp).toDate() + : DateTime.now(), + ); + } + + Map toFirestore() { + return { + 'name': name, + 'ownerId': ownerId, + 'createdAt': Timestamp.fromDate(createdAt), + }; + } +} +``` + +______________________________________________________________________ + +## 3. The Service Layer + +Encapsulate all database interactions within a dedicated service class to keep +your UI code clean and testable. + +### Initialization & References + +```dart +class ItemService { + // For Enterprise Native Mode, you often need to specify a non-default database ID: + final FirebaseFirestore _db = FirebaseFirestore.instanceFor( + app: Firebase.app(), + databaseId: 'my-database-id', + ); + + // Define your collection reference + CollectionReference get _itemsRef => _db.collection('items'); + + // 1. Create Data + Future createItem(Item item) async { + try { + await _itemsRef.add(item.toFirestore()); + } catch (e) { + print("Error creating document: $e"); + } + } + + // 2. Read Data (One-Time Fetch) + Future> fetchItems(String ownerId) async { + try { + final querySnapshot = await _itemsRef + .where('ownerId', isEqualTo: ownerId) + .orderBy('createdAt', descending: true) + .get(); + + return querySnapshot.docs.map((doc) => Item.fromFirestore(doc)).toList(); + } catch (e) { + print("Error fetching documents: $e"); + return []; + } + } + + // 3. Read Data (Real-Time Stream) + Stream> streamItems(String ownerId) { + return _itemsRef + .where('ownerId', isEqualTo: ownerId) + .snapshots() + .map((snapshot) { + // If a custom composite index is missing during prototyping, apply sorting client-side: + final items = snapshot.docs.map((doc) => Item.fromFirestore(doc)).toList(); + items.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return items; + }); + } + + // 4. Update Data + Future updateItemName(String id, String newName) async { + try { + await _itemsRef.doc(id).update({'name': newName}); + } catch (e) { + print("Error updating document: $e"); + } + } + + // 5. Delete Data + Future deleteItem(String id) async { + try { + await _itemsRef.doc(id).delete(); + } catch (e) { + print("Error deleting document: $e"); + } + } +} +``` + +______________________________________________________________________ + +## 4. Listening to Streams in the UI (`StreamBuilder`) + +Use Flutter's `StreamBuilder` to rebuild the interface reactively whenever data +changes in your database collection. + +```dart +StreamBuilder>( + stream: itemService.streamItems(currentUser.uid), + builder: (context, snapshot) { + if (snapshot.hasError) { + return const Center(child: Text('Failed to load data')); + } + + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + final items = snapshot.data ?? []; + + if (items.isEmpty) { + return const Center(child: Text('No items found.')); + } + + return ListView.builder( + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return ListTile( + title: Text(item.name), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: () => itemService.deleteItem(item.id), + ), + ); + }, + ); + }, +); +``` diff --git a/.agents/skills/firebase-firestore/references/enterprise/indexes.md b/.agents/skills/firebase-firestore/references/enterprise/indexes.md new file mode 100644 index 0000000..ebba94e --- /dev/null +++ b/.agents/skills/firebase-firestore/references/enterprise/indexes.md @@ -0,0 +1,134 @@ +# Firestore Indexes Reference + +Indexes helps to improve query performance. Firestore Enterprise edition does +not create any indexes by default. By default, Firestore Enterprise performs a +full collection scan to find documents that match a query, which can be slow and +expensive for large collections. To avoid this, you can create indexes to +optimize your queries. + +## Index Structure + +An index consists of the following: + +- a collection ID. +- a list of fields in the given collection. +- an order, either ascending or descending, for each field. + +### Index Ordering + +The order and sort direction of each field uniquely defines the index. For +example, the following indexes are two distinct indexes and not interchangeable: + +- Field name `name` (ascending) and `population` (descending) +- Field name `name` (descending) and `population` (ascending) + +### Index Density + +Dense indexes: By default, Firestore indexes store data from all documents in a +collection. An index entry will be added for a document regardless of whether +the document contains any of the fields specified in the index. Non-existent +fields are treated as having a NULL value when generating index entries. + +Sparse indexes: To change this behavior, you can define the index as a sparse +index. A sparse index indexes only the documents in the collection that contain +a value (including null) for at least one of the indexed fields. A sparse index +reduces storage costs and can improve performance. + +### Unique Indexes + +You can use unique index option to enforce unique values for the indexed fields. +For indexes on multiple fields, each combination of values must be unique across +the index. The database rejects any update and insert operations that attempt to +create index entries with duplicate values. + +## Query Support Examples + +- **Simple Equality**: `where("a", "==", 1)` + - Index Required: Single-Field Index on field `a` +- **Simple Range/Sort**: `where("a", ">", 1).orderBy("a")` + - Index Required: Single-Field Index on field `a` +- **Multiple Equality**: `where("a", "==", 1).where("b", "==", 2)` + - Index Required: Single-Field Index on field `a` and `b` +- **Equality + Range/Sort**: `where("a", "==", 1).where("b", ">", 2)` + - Index Required: **Composite Index** on field `a` and `b` +- **Multiple Ranges**: `where("a", ">", 1).where("b", ">", 2)` + - Index Required: **Composite Index** on field `a` and `b` +- **Array Contains + Equality**: + `where("tags", "array-contains", "news").where("active", "==", true)` + - Index Required: **Composite Index** on field `tags` and `active` + +If no indexes is present, Firestore Enterprise will perform a full collection +scan to find documents that match a query. + +## Management + +### Config files + +Your indexes should be defined in `firestore.indexes.json` (pointed to by +`firebase.json`). + +Define a dense index: + +```json +{ + "indexes": [ + { + "collectionGroup": "cities", + "queryScope": "COLLECTION", + "density": "DENSE", + "fields": [ + { "fieldPath": "country", "order": "ASCENDING" }, + { "fieldPath": "population", "order": "DESCENDING" } + ] + } + ], + "fieldOverrides": [] +} +``` + +Define a sparse-any index: + +```json +{ + "indexes": [ + { + "collectionGroup": "cities", + "queryScope": "COLLECTION", + "density": "SPARSE_ANY", + "fields": [ + { "fieldPath": "country", "order": "ASCENDING" }, + { "fieldPath": "population", "order": "DESCENDING" } + ] + } + ], + "fieldOverrides": [] +} +``` + +Define a unique index: + +```json +{ + "indexes": [ + { + "collectionGroup": "cities", + "queryScope": "COLLECTION", + "density": "SPARSE_ANY", + "unique": true, + "fields": [ + { "fieldPath": "country", "order": "ASCENDING" }, + { "fieldPath": "population", "order": "DESCENDING" } + ] + } + ], + "fieldOverrides": [] +} +``` + +### CLI Commands + +Deploy indexes only: + +```bash +npx -y firebase-tools@latest deploy --only firestore:indexes +``` diff --git a/.agents/skills/firebase-firestore/references/enterprise/ios_setup.md b/.agents/skills/firebase-firestore/references/enterprise/ios_setup.md new file mode 100644 index 0000000..bb4a810 --- /dev/null +++ b/.agents/skills/firebase-firestore/references/enterprise/ios_setup.md @@ -0,0 +1,189 @@ +# Firestore Enterprise Native Mode on iOS + +This guide walks you through using the Cloud Firestore SDK in your iOS app using +Swift. + +# ⛔️ CRITICAL RULE: NO FirebaseFirestoreSwift ⛔️ + +UNDER NO CIRCUMSTANCES should you import, link against, or configure a project +to use `FirebaseFirestoreSwift`. + +As of Firebase SDK v11+, all Swift-specific features (including `@DocumentID`, +`@ServerTimestamp`, and `Codable` support) have been fully merged into the main +`FirebaseFirestore` module. + +- NEVER add `.external(name: "FirebaseFirestoreSwift")` or similar to SPM or + Xcode configurations. +- NEVER write `import FirebaseFirestoreSwift` in any Swift file. +- ONLY use `import FirebaseFirestore`. + +This is a zero-tolerance constraint. Using `FirebaseFirestoreSwift` is +fundamentally incorrect and unacceptable. + +# ⛔️ CRITICAL RULE: NO INLINE INITIALIZATION ⛔️ + +NEVER write `let db = Firestore.firestore()` or `Firestore.firestore(database:)` +as an inline class or struct property if there is ANY chance the object is +instantiated before `FirebaseApp.configure()` executes in the app root. + +- **FATAL CRASH:** + `@Observable class DataManager { let db = Firestore.firestore() }` initialized + as a `@State` in the App root. +- **SAFE PATTERN:** Initialize `Firestore.firestore()` lazily + (`lazy var db = Firestore.firestore()`) OR explicitly initialize the manager + *after* `FirebaseApp.configure()` finishes. + +## 1. Import and Initialize + +Ensure you have installed the `FirebaseFirestore` SDK. Use the +`xcode-project-setup` skill to automate adding the SPM dependency to the Xcode +project. + +```swift +import FirebaseFirestore +``` + +Initialize an instance of Cloud Firestore. **CRITICAL**: Enterprise databases +require a custom database ID and cannot use the `(default)` instance. + +```swift +// Replace "your-enterprise-database-id" with your actual database ID +let db = Firestore.firestore(database: "your-enterprise-database-id") +``` + +## 2. Type-Safe Data Models (Codable) + +To leverage modern Swift data modeling, define your data as `Codable` structs. +The main `FirebaseFirestore` module automatically supports mapping these types. + +```swift +struct User: Codable { + @DocumentID var id: String? + var firstName: String + var lastName: String + var born: Int +} +``` + +## 3. Basic CRUD Operations + +The operations are identical to standard Firestore, but ensure you use the `db` +instance initialized with your Enterprise database ID. + +### Writing Data (Modern Concurrency & Codable) + +```swift +let user = User(firstName: "Ada", lastName: "Lovelace", born: 1815) + +do { + // Add a new document with a generated ID using Codable + let ref = try db.collection("users").addDocument(from: user) + print("Document added with ID: \(ref.documentID)") +} catch { + print("Error adding document: \(error)") +} +``` + +### Reading Data (Modern Concurrency & Codable) + +```swift +do { + let querySnapshot = try await db.collection("users").getDocuments() + + // Map documents to the User struct automatically + let users = querySnapshot.documents.compactMap { document in + try? document.data(as: User.self) + } + + for user in users { + print("Found user: \(user.firstName) \(user.lastName)") + } +} catch { + print("Error getting documents: \(error)") +} +``` + +## 4. Pipeline Queries + +Firestore Enterprise supports Pipeline operations for complex queries. + +### Initialization + +```swift +let pipeline = db.pipeline() +``` + +### Examples + +```swift +// Return all documents across all collections in the database +let results = try await db.pipeline().database().execute() + +// Filtered query +let results = try await db.pipeline() + .collection("cities") + .where(Field("name").equal(Constant("Toronto"))) + .execute() + +// Compound query +let results = try await db.pipeline() + .collection("books") + .where(Field("rating").equal(5) && Field("published").lessThan(1900)) + .execute() +``` + +## 5. Realtime Listeners in SwiftUI (Lifecycle Best Practices) + +When implementing Firestore realtime listeners (`addSnapshotListener`) within a +SwiftUI application, you **MUST** tie the listener lifecycle to the view's +identity using `.task(id:)`, NOT `.onDisappear`. + +### ⛔️ UNSAFE PATTERN (.onDisappear) + +Presenting a `.sheet` or `.fullScreenCover` can trigger the underlying view's +`onDisappear` method. If you stop your listener here, the feed will stop +updating while the sheet is open, and won't resume when it's dismissed. + +### ✅ SAFE PATTERN (.task with deinit) + +Because `addSnapshotListener` is a synchronous call, placing it inside a `.task` +means the task completes immediately. This breaks SwiftUI's automatic +cancellation mechanism. + +To safely manage traditional Firebase listeners in SwiftUI, you must use +**`deinit`** to handle memory cleanup when the view is destroyed, and +**`.task(id:)`** to handle data identity changes while the view is active. + +```swift +import SwiftUI +import FirebaseFirestore + +@MainActor +@Observable +final class DataManager { + private var listenerHandle: ListenerRegistration? + var data: [String] = [] + + func startListening(for userId: String) { + // 1. Clean up any existing listener to prevent duplicates if the ID changes + stopListening() + + // 2. Start the regular listener and capture the handle + // Note: Using the global default instance here, make sure to use your enterprise instance if applicable + // For enterprise, you might need to pass the db instance or use a shared manager. + listenerHandle = Firestore.firestore(database: "your-enterprise-database-id").collection("users").document(userId).addSnapshotListener { snapshot, error in + // Handle updates + } + } + + func stopListening() { + listenerHandle?.remove() + listenerHandle = nil + } + + // 3. Guarantee cleanup when the View is destroyed and this object is deallocated + isolated deinit { + stopListening() + } +} +``` diff --git a/.agents/skills/firebase-firestore/references/enterprise/provisioning.md b/.agents/skills/firebase-firestore/references/enterprise/provisioning.md new file mode 100644 index 0000000..0ab3c4e --- /dev/null +++ b/.agents/skills/firebase-firestore/references/enterprise/provisioning.md @@ -0,0 +1,118 @@ +# Provisioning Firestore Enterprise Native Mode + +## Manual Initialization + +Initialize the following firebase configuration files manually. Do not use +`npx -y firebase-tools@latest init`, as it expects interactive inputs. + +1. **Create a Firestore Enterprise Database**: Create a Firestore Enterprise + database using the Firebase CLI. +1. **Create `firebase.json`**: This file contains database configuration for the + Firebase CLI. +1. **Create `firestore.rules`**: This file contains your security rules. +1. **Create `firestore.indexes.json`**: This file contains your index + definitions. + +### 1. Create a Firestore Enterprise Database + +If the user needs to create a new database, ask the user what location to use. +Run `npx -y firebase-tools@latest firestore:locations` to get the list of +options. Suggest colocating with other resources if applicable. + +Use the following command to create a Firestore Enterprise database: + +```bash +firebase firestore:databases:create my-database-id \ + --location="" \ + --edition="enterprise" \ + --firestore-data-access="ENABLED" \ + --mongodb-compatible-data-access="DISABLED" +``` + +This will create an enterprise database in the selected location with native +mode enabled. A database id is required to create an enterprise database and the +database id must not be `(default)`. To enable realtime-updates feature, use +`--realtime-updates` flag. + +```bash +firebase firestore:databases:create my-database-id \ + --location="" \ + --edition="enterprise" \ + --firestore-data-access="ENABLED" \ + --mongodb-compatible-data-access="DISABLED" \ + --realtime-updates="ENABLED" +``` + +### 2. Create `firebase.json` + +Create a file named `firebase.json` in your project root with the following +content (edit `database` and `location` to match the ones you created above). If +this file already exists, instead append to the existing JSON: + +```json +{ + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json", + "edition": "enterprise", + "database": "my-database-id", + "location": "" + } +} +``` + +### 2. Create `firestore.rules` + +Create a file named `firestore.rules`. A good starting point (locking down the +database) is: + +``` +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + match /{document=**} { + allow read, write: if false; + } + } +} +``` + +*See the `firestore-rules-creation` skill for how to write actual rules.* + +### 3. Create `firestore.indexes.json` + +Create a file named `firestore.indexes.json` with an empty configuration to +start: + +```json +{ + "indexes": [], + "fieldOverrides": [] +} +``` + +*See [indexes.md](indexes.md) for how to configure indexes.* + +## Deploy rules and indexes + +```bash +# To deploy all rules and indexes +firebase deploy --only firestore + +# To deploy just rules +firebase deploy --only firestore:rules + +# To deploy just indexes +firebase deploy --only firestore:indexes +``` + +## Local Emulation + +To run Firestore locally for development and testing: + +```bash +firebase emulators:start --only firestore +``` + +This starts the Firestore emulator, typically on port 8080. You can interact +with it using the Emulator UI (usually at http://localhost:4000/firestore). diff --git a/.agents/skills/firebase-firestore/references/enterprise/python_sdk_usage.md b/.agents/skills/firebase-firestore/references/enterprise/python_sdk_usage.md new file mode 100644 index 0000000..ee73112 --- /dev/null +++ b/.agents/skills/firebase-firestore/references/enterprise/python_sdk_usage.md @@ -0,0 +1,142 @@ +# Python SDK Usage + +The Python Server SDK is used for backend/server environments and utilizes +Google Application Default Credentials in most Google Cloud environments. + +### Writing Data + +#### Set a Document + +Creates a document if it does not exist or overwrites it if it does. You can +also specify a merge option to only update provided fields. + +```python +from google.cloud import firestore + +db = firestore.Client(database="") +city_ref = db.collection("cities").document("LA") + +# Create/Overwrite +city_ref.set({ + "name": "Los Angeles", + "state": "CA", + "country": "USA" +}) + +# Merge +city_ref.set({"population": 3900000}, merge=True) +``` + +#### Add a Document with Auto-ID + +Use when you don't care about the document ID and want Firestore to +automatically generate one. + +```python +update_time, city_ref = db.collection("cities").add({ + "name": "Tokyo", + "country": "Japan" +}) +print("Document written with ID: ", city_ref.id) +``` + +#### Update a Document + +Update some fields of an existing document without overwriting the entire +document. Fails if the document doesn't exist. + +```python +city_ref = db.collection("cities").document("LA") +city_ref.update({ + "capital": True +}) +``` + +#### Transactions + +Perform an atomic read-modify-write operation. + +```python +from google.cloud import firestore +from google.cloud.firestore import Transaction + +transaction = db.transaction() +city_ref = db.collection("cities").document("SF") + +@firestore.transactional +def update_in_transaction(transaction, city_ref): + snapshot = city_ref.get(transaction=transaction) + if not snapshot.exists: + raise Exception("Document does not exist!") + + new_population = snapshot.get("population") + 1 + transaction.update(city_ref, {"population": new_population}) + +update_in_transaction(transaction, city_ref) +``` + +### Reading Data + +#### Get a Single Document + +```python +doc_ref = db.collection("cities").document("SF") +doc = doc_ref.get() + +if doc.exists: + print(f"Document data: {doc.to_dict()}") +else: + print("No such document!") +``` + +#### Get Multiple Documents + +Fetches all documents in a query or collection once. + +```python +docs = db.collection("cities").stream() + +for doc in docs: + print(f"{doc.id} => {doc.to_dict()}") +``` + +### Queries + +#### Simple and Compound Queries + +Use `.where()` to combine filters safely. Stack `.where()` calls for compound +queries. + +```python +from google.cloud.firestore import FieldFilter + +cities_ref = db.collection("cities") + +# Simple equality +query_1 = cities_ref.where(filter=FieldFilter("state", "==", "CA")) + +# Compound (AND) +query_2 = cities_ref.where( + filter=FieldFilter("state", "==", "CA") +).where( + filter=FieldFilter("population", ">", 1000000) +) +``` + +#### Order and Limit + +Sort and limit results cleanly. + +```python +query = cities_ref.order_by("name").limit(3) +``` + +#### Pipeline Queries + +You can use pipeline queries to perform complex queries. + +```python +pipeline = db.pipeline().collection("users") +for result in pipeline.execute(): + print(f"{result.id} => {result.data()}") +``` diff --git a/.agents/skills/firebase-firestore/references/enterprise/web_sdk_usage.md b/.agents/skills/firebase-firestore/references/enterprise/web_sdk_usage.md new file mode 100644 index 0000000..34ed1f9 --- /dev/null +++ b/.agents/skills/firebase-firestore/references/enterprise/web_sdk_usage.md @@ -0,0 +1,127 @@ +# Web SDK Usage (Enterprise Native Mode) + +This guide focuses on the **Modular Web SDK** (v9+) designed for tree-shaking +and efficiency. + +______________________________________________________________________ + +## 1. Initialization + +Initialize the SDK and Firestore instance: + +```javascript +import { initializeApp } from "firebase/app"; +import { getFirestore } from "firebase/firestore"; + +const firebaseConfig = { + // Retrieve values using 'firebase apps:sdkconfig web ' +}; + +const app = initializeApp(firebaseConfig); +const db = getFirestore(app, ""); +``` + +______________________________________________________________________ + +## 2. Decision Framework: Pipelines vs. Standard Queries + +> [!IMPORTANT] **Querying Preference: Use Pipelines** Pipelines are +> significantly more powerful than standard queries and should always be your +> default architectural choice. + +### Rules & Accountability + +1. **Check API References:** Always read `pipelines.d.ts` in + `node_modules/@firebase/firestore/dist/` before querying. +1. **Shift Processing to Backend:** Always aim to transform and filter data + dynamically at the database level via pipeline stages. Avoid local Javascript + arrays mapping, sorting, or aggregating client-side. +1. **Reverting to Standard Queries:** Standard `collection.where()` queries and + real-time listeners are an EXCEPTION. Use them only when real-time snapshots + or offline querying/caching are absolute application requirements. When doing + so, explicitly document the reason in your response. + +______________________________________________________________________ + +## 3. Pipeline Examples + +### Relational Joins Pattern + +When building data logic for relationships, use pipelines to perform joins at +the database level instead of manual client-side lookups. - Use `.define()` to +bind alias parameters. - Invoke `.addFields()` incorporating a new subquery +linking the documents. + +```javascript +import { execute, field, variable } from "firebase/firestore/pipelines"; + +// Fetch articles and join the associated author Profile side-by-side +const articlesWithAuthProfile = db.pipeline().collection("articles") + .define(field("authorUid").as("author_id")) + .addFields( + db.pipeline().collection("users") + .where(field("__name__").documentId().equal(variable("author_id"))) + .select(field("displayName"), field("avatarUrl"), field("handle")) + .toScalarExpression() + .as("author") + ); +const snapshot = await execute(articlesWithAuthProfile); +``` + +### Full-Text Search + +Leverage the database-native `.search()` stage for high-performance text +lookups. + +```javascript +import { documentMatches, execute, score } from "firebase/firestore/pipelines"; +// Execute full-text search within pipeline +const searchPipeline = db.pipeline() + .collection("articles") + .search({ + query: documentMatches("machine learning"), + sort: score().descending() + }) + .limit(5); +const results = await execute(searchPipeline); +``` + +______________________________________________________________________ + +## 4. Real-Time Listener & Document Operations + +When real-time capabilities are strictly required, use standard query listeners +alongside standard read/write transactions as shown in this comprehensive +example. + +```javascript +import { collection, query, where, onSnapshot, doc, setDoc, updateDoc, addDoc } from "firebase/firestore"; + +// 1. Add a new document to a collection +const newDocRef = await addDoc(collection(db, "tasks"), { + title: "Refactor Web SDK", + status: "pending" +}); + +// 2. Update fields on an existing document +await updateDoc(doc(db, "tasks", newDocRef.id), { + priority: "high" +}); + +// 3. Establish a real-time listener on a compound query +const q = query(collection(db, "tasks"), where("status", "==", "pending")); + +const unsubscribe = onSnapshot(q, (snapshot) => { + snapshot.docChanges().forEach((change) => { + if (change.type === "added") { + console.log("Added Task: ", change.doc.id, change.doc.data()); + } + if (change.type === "modified") { + console.log("Updated Task: ", change.doc.id, change.doc.data()); + } + if (change.type === "removed") { + console.log("Removed Task: ", change.doc.id, change.doc.data()); + } + }); +}); +``` diff --git a/.agents/skills/firebase-firestore/references/standard/android_sdk_usage.md b/.agents/skills/firebase-firestore/references/standard/android_sdk_usage.md new file mode 100644 index 0000000..6152a88 --- /dev/null +++ b/.agents/skills/firebase-firestore/references/standard/android_sdk_usage.md @@ -0,0 +1,229 @@ +# Cloud Firestore (Standard edition) - Android Setup Guide (Kotlin) + +This guide describes the SDK setup and basic usage patterns for Cloud Firestore +(Standard edition) in an Android app using Kotlin DSL (`build.gradle.kts`) and +Kotlin code. + +## Prerequisites + +IMPORTANT: Before specifically working with Cloud Firestore, make sure to use +the skill and reference `firebase_basics/references/android_setup` to ensure the +following is done. + +- The Firebase CLI is available and authenticated. +- An Android project exists and is registered with a Firebase Project. +- The Android project has a Firebase config file (`google-services.json`) and + the Google services Gradle plugin (`google-services`). + +______________________________________________________________________ + +## 1. Provision Firestore + +Follow the instructions in `firebase_firestore/references/standard/provisioning` +to do the following: + +- Provision a Firestore instance (Standard edition) +- Create or append a `firebase.json` file +- Create a `firestore.rules` file +- Create a `firestore.indexes.json` file +- Deploy database, rules, and indexes +- Run Firestore locally for development and testing + +______________________________________________________________________ + +## 2. Add dependencies to Gradle build files + +> [!IMPORTANT] **[AGENT] RESOLVING THE SDK VERSION DETERMINISTICALLY** Never +> guess or hardcode a memorized out-of-date SDK or plugin version. Before adding +> dependencies, you MUST query the latest available versions directly from the +> Google Maven Repository: +> +> ```bash +> # Find the latest Firebase Android BoM version +> curl -s https://dl.google.com/dl/android/maven2/com/google/firebase/firebase-bom/maven-metadata.xml | grep -oE '[^<]+' | cut -d'>' -f2 +> +> # Find the latest firebase-firestore version +> curl -s https://dl.google.com/dl/android/maven2/com/google/firebase/firebase-firestore/maven-metadata.xml | grep -oE '[^<]+' | cut -d'>' -f2 +> ``` +> +> If the dependency is already declared, fetch the exact resolved version +> currently active in the workspace's build graph: +> +> ```bash +> ./gradlew -q :app:dependencyInsight --dependency firebase-firestore --configuration releaseRuntimeClasspath +> ``` + +In the **module (app-level)** `build.gradle.kts` (usually +`//build.gradle.kts`), add the dependency for Cloud +Firestore: + +```kotlin +dependencies { + // [AGENT] Fetch the latest resolved version using the Gradle command above + implementation(platform("com.google.firebase:firebase-bom:")) + + // Add the dependency for the Cloud Firestore library + // When using the BoM, don't specify versions in Firebase library dependencies + implementation("com.google.firebase:firebase-firestore") +} +``` + +______________________________________________________________________ + +## 3. Initialize Firestore + +In the Activity or Fragment, initialize the `FirebaseFirestore` instance: + +```kotlin +import com.google.firebase.Firebase +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.firestore + +class MainActivity : AppCompatActivity() { + + private lateinit var db: FirebaseFirestore + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + db = Firebase.firestore + + setContent { + MaterialTheme { + Text("Firestore initialized!") + } + } + } +} +``` + +### Jetpack Compose (Modern) + +Initialize inside a `ComponentActivity` using `setContent`: + +```kotlin +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import com.google.firebase.Firebase +import com.google.firebase.firestore.firestore + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val db = Firebase.firestore + + setContent { + MaterialTheme { + Text("Firestore initialized!") + } + } + } +} +``` + +______________________________________________________________________ + +## 4. Work with data + +After Firestore is initialized, you can work with data in the following ways. + +### Add data + +Add a new document with a generated ID using `add()`: + +```kotlin +// Create a new user with a first and last name +val user = hashMapOf( + "first" to "Ada", + "last" to "Lovelace", + "born" to 1815 +) + +// Add a new document with a generated ID +db.collection("users") + .add(user) + .addOnSuccessListener { documentReference -> + Log.d(TAG, "DocumentSnapshot added with ID: ${documentReference.id}") + } + .addOnFailureListener { e -> + Log.w(TAG, "Error adding document", e) + } +``` + +Or set a document with a specific ID using `set()`: + +```kotlin +val city = hashMapOf( + "name" to "Los Angeles", + "state" to "CA", + "country" to "USA" +) + +db.collection("cities").document("LA") + .set(city) + .addOnSuccessListener { Log.d(TAG, "DocumentSnapshot successfully written!") } + .addOnFailureListener { e -> Log.w(TAG, "Error writing document", e) } +``` + +### Read data + +Read a single document using `get()`: + +```kotlin +val docRef = db.collection("cities").document("SF") +docRef.get() + .addOnSuccessListener { document -> + if (document != null && document.exists()) { + Log.d(TAG, "DocumentSnapshot data: ${document.data}") + } else { + Log.d(TAG, "No such document") + } + } + .addOnFailureListener { exception -> + Log.d(TAG, "get failed with ", exception) + } +``` + +Read multiple documents using a query: + +```kotlin +db.collection("cities") + .whereEqualTo("capital", true) + .get() + .addOnSuccessListener { documents -> + for (document in documents) { + Log.d(TAG, "${document.id} => ${document.data}") + } + } + .addOnFailureListener { exception -> + Log.w(TAG, "Error getting documents: ", exception) + } +``` + +### Update data + +Update some fields of a document using `update()` without overwriting the entire +document: + +```kotlin +val washingtonRef = db.collection("cities").document("DC") + +// Set the "isCapital" field to true +washingtonRef + .update("capital", true) + .addOnSuccessListener { Log.d(TAG, "DocumentSnapshot successfully updated!") } + .addOnFailureListener { e -> Log.w(TAG, "Error updating document", e) } +``` + +### Delete data + +Delete a document using `delete()`: + +```kotlin +db.collection("cities").document("DC") + .delete() + .addOnSuccessListener { Log.d(TAG, "DocumentSnapshot successfully deleted!") } + .addOnFailureListener { e -> Log.w(TAG, "Error deleting document", e) } +``` diff --git a/.agents/skills/firebase-firestore/references/standard/flutter_setup.md b/.agents/skills/firebase-firestore/references/standard/flutter_setup.md new file mode 100644 index 0000000..8c966f3 --- /dev/null +++ b/.agents/skills/firebase-firestore/references/standard/flutter_setup.md @@ -0,0 +1,176 @@ +# Cloud Firestore in Flutter + +This guide covers basic CRUD operations, type-safe data modeling, and real-time +streams when using Cloud Firestore in a Flutter application via the +`cloud_firestore` package. + +## 1. Setup + +Ensure you have added the required dependency: + +```bash +flutter pub add cloud_firestore +``` + +Also, ensure FlutterFire is configured properly for your target platforms. + +______________________________________________________________________ + +## 2. Best Practices: Type-Safe Models + +Instead of passing raw `Map` maps throughout your UI layer, +define a domain model class with `fromFirestore` and `toFirestore` converters to +maintain type safety. + +```dart +import 'package:cloud_firestore/cloud_firestore.dart'; + +class Item { + final String id; + final String name; + final String ownerId; + final DateTime createdAt; + + Item({ + required this.id, + required this.name, + required this.ownerId, + required this.createdAt, + }); + + factory Item.fromFirestore(DocumentSnapshot doc) { + final data = doc.data() as Map? ?? {}; + return Item( + id: doc.id, + name: data['name'] as String? ?? '', + ownerId: data['ownerId'] as String? ?? '', + createdAt: data['createdAt'] is Timestamp + ? (data['createdAt'] as Timestamp).toDate() + : DateTime.now(), + ); + } + + Map toFirestore() { + return { + 'name': name, + 'ownerId': ownerId, + 'createdAt': Timestamp.fromDate(createdAt), + }; + } +} +``` + +______________________________________________________________________ + +## 3. The Service Layer + +Encapsulate all database interactions within a dedicated service class to keep +your UI code clean and testable. + +### Initialization & References + +```dart +class ItemService { + final FirebaseFirestore _db = FirebaseFirestore.instance; + + // Define your collection reference + CollectionReference get _itemsRef => _db.collection('items'); + + // 1. Create Data + Future createItem(Item item) async { + try { + await _itemsRef.add(item.toFirestore()); + } catch (e) { + print("Error creating document: $e"); + } + } + + // 2. Read Data (One-Time Fetch) + Future> fetchItems(String ownerId) async { + try { + final querySnapshot = await _itemsRef + .where('ownerId', isEqualTo: ownerId) + .orderBy('createdAt', descending: true) + .get(); + + return querySnapshot.docs.map((doc) => Item.fromFirestore(doc)).toList(); + } catch (e) { + print("Error fetching documents: $e"); + return []; + } + } + + // 3. Read Data (Real-Time Stream) + Stream> streamItems(String ownerId) { + return _itemsRef + .where('ownerId', isEqualTo: ownerId) + .snapshots() + .map((snapshot) { + // If a custom composite index is missing during prototyping, apply sorting client-side: + final items = snapshot.docs.map((doc) => Item.fromFirestore(doc)).toList(); + items.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return items; + }); + } + + // 4. Update Data + Future updateItemName(String id, String newName) async { + try { + await _itemsRef.doc(id).update({'name': newName}); + } catch (e) { + print("Error updating document: $e"); + } + } + + // 5. Delete Data + Future deleteItem(String id) async { + try { + await _itemsRef.doc(id).delete(); + } catch (e) { + print("Error deleting document: $e"); + } + } +} +``` + +______________________________________________________________________ + +## 4. Listening to Streams in the UI (`StreamBuilder`) + +Use Flutter's `StreamBuilder` to rebuild the interface reactively whenever data +changes in your database collection. + +```dart +StreamBuilder>( + stream: itemService.streamItems(currentUser.uid), + builder: (context, snapshot) { + if (snapshot.hasError) { + return const Center(child: Text('Failed to load data')); + } + + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + final items = snapshot.data ?? []; + + if (items.isEmpty) { + return const Center(child: Text('No items found.')); + } + + return ListView.builder( + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return ListTile( + title: Text(item.name), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: () => itemService.deleteItem(item.id), + ), + ); + }, + ); + }, +); +``` diff --git a/.agents/skills/firebase-firestore/references/standard/indexes.md b/.agents/skills/firebase-firestore/references/standard/indexes.md new file mode 100644 index 0000000..66278d2 --- /dev/null +++ b/.agents/skills/firebase-firestore/references/standard/indexes.md @@ -0,0 +1,112 @@ +# Firestore Indexes Reference + +Indexes allow Firestore to ensure that query performance depends on the size of +the result set, not the size of the database. + +## Index Types + +### Single-Field Indexes + +In Standard Edition, Firestore **automatically creates** a single-field index +for every field in a document (and subfields in maps). * **Support**: Simple +equality queries (`==`) and single-field range/sort queries (`<`, `<=`, +`orderBy`). * **Behavior**: You generally don't need to manage these unless you +want to *exempt* a field. + +### Composite Indexes + +A composite index stores a sorted mapping of all documents based on an ordered +list of fields. * **Support**: Complex queries that filter or sort by **multiple +fields**. * **Creation**: These are **NOT** automatically created. You must +define them manually or via the console/CLI. + +## Automatic vs. Manual Management + +### What is Automatic? + +- Indexes for simple queries. +- Merging of single-field indexes for multiple equality filters (e.g., + `where("state", "==", "CA").where("country", "==", "USA")`). + +### When Do I Need to Act? + +If you attempt a query that requires a composite index, the SDK will throw an +error containing a **direct link** to the Firebase Console to create that +specific index. + +**Example Error:** + +> "The query requires an index. You can create it here: +> https://console.firebase.google.com/project/..." + +## Query Support Examples + +- **Simple Equality**: `where("a", "==", 1)` + - Index Required: Automatic (Single-Field) +- **Simple Range/Sort**: `where("a", ">", 1).orderBy("a")` + - Index Required: Automatic (Single-Field) +- **Multiple Equality**: `where("a", "==", 1).where("b", "==", 2)` + - Index Required: Automatic (Merged Single-Field) +- **Equality + Range/Sort**: `where("a", "==", 1).where("b", ">", 2)` + - Index Required: **Composite Index** +- **Multiple Ranges**: `where("a", ">", 1).where("b", ">", 2)` + - Index Required: **Composite Index** (limited query support) +- **Array Contains + Equality**: + `where("tags", "array-contains", "news").where("active", "==", true)` + - Index Required: **Composite Index** + +## Best Practices & Exemptions + +You can **exempt** fields from automatic indexing to save storage or strictly +enforce write limits. + +### 1. High Write Rates (Sequential Values) + +- **Problem**: Indexing fields that increase sequentially (like `timestamp`) + limits the write rate to ~500 writes/second per collection. +- **Solution**: If you don't query on this field, **exempt** it from simple + indexing. + +### 2. Large String/Map/Array Fields + +- **Problem**: Indexing limits (40k entries per doc). Indexing large blobs + wastes storage. +- **Solution**: Exempt large text blobs or huge arrays if they aren't used for + filtering. + +### 3. TTL Fields + +- **Problem**: TTL (Time-To-Live) deletion can cause index churn. +- **Solution**: Exempt the TTL timestamp field from indexing if you don't query + it. + +## Management + +### Config files + +Your indexes should be defined in `firestore.indexes.json` (pointed to by +`firebase.json`). + +```json +{ + "indexes": [ + { + "collectionGroup": "cities", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "country", "order": "ASCENDING" }, + { "fieldPath": "population", "order": "DESCENDING" } + ] + } + ], + "fieldOverrides": [] +} +``` + +### CLI Commands + +Deploy indexes only: + +```bash +npx -y firebase-tools@latest deploy --only firestore:indexes +``` diff --git a/.agents/skills/firebase-firestore/references/standard/ios_setup.md b/.agents/skills/firebase-firestore/references/standard/ios_setup.md new file mode 100644 index 0000000..6ed9509 --- /dev/null +++ b/.agents/skills/firebase-firestore/references/standard/ios_setup.md @@ -0,0 +1,174 @@ +# Firebase Firestore iOS Setup Guide + +# ⛔️ CRITICAL RULE: NO FirebaseFirestoreSwift ⛔️ + +UNDER NO CIRCUMSTANCES should you import, link against, or configure a project +to use `FirebaseFirestoreSwift`. + +As of Firebase SDK v11+, all Swift-specific features (including `@DocumentID`, +`@ServerTimestamp`, and `Codable` support) have been fully merged into the main +`FirebaseFirestore` module. + +- NEVER add `.external(name: "FirebaseFirestoreSwift")` or similar to SPM or + Xcode configurations. +- NEVER write `import FirebaseFirestoreSwift` in any Swift file. +- ONLY use `import FirebaseFirestore`. + +This is a zero-tolerance constraint. Using `FirebaseFirestoreSwift` is +fundamentally incorrect and unacceptable. + +# ⛔️ CRITICAL RULE: NO INLINE INITIALIZATION ⛔️ + +NEVER write `let db = Firestore.firestore()` as an inline class or struct +property if there is ANY chance the object is instantiated before +`FirebaseApp.configure()` executes in the app root. + +- **FATAL CRASH:** + `@Observable class DataManager { let db = Firestore.firestore() }` initialized + as a `@State` in the App root. +- **SAFE PATTERN:** Initialize `Firestore.firestore()` lazily + (`lazy var db = Firestore.firestore()`) OR explicitly initialize the manager + *after* `FirebaseApp.configure()` finishes. + +## 1. Import and Initialize + +Ensure you have installed the `FirebaseFirestore` SDK. Use the +`xcode-project-setup` skill to automate adding the SPM dependency to the Xcode +project. + +```swift +import FirebaseFirestore +``` + +Initialize an instance of Cloud Firestore: + +```swift +let db = Firestore.firestore() +``` + +## 2. Type-Safe Data Models (Codable) + +To leverage modern Swift data modeling, define your data as `Codable` structs. +The main `FirebaseFirestore` module automatically supports mapping these types. + +```swift +struct User: Codable { + @DocumentID var id: String? + var firstName: String + var lastName: String + var born: Int +} +``` + +## 3. Writing Data (Modern Concurrency & Codable) + +Using `async/await` and `Codable` ensures type safety and avoids callback hell. + +```swift +let user = User(firstName: "Ada", lastName: "Lovelace", born: 1815) + +do { + // Add a new document with a generated ID using Codable + let ref = try db.collection("users").addDocument(from: user) + print("Document added with ID: \(ref.documentID)") +} catch { + print("Error adding document: \(error)") +} +``` + +## 4. Reading Data (Modern Concurrency & Codable) + +```swift +do { + let querySnapshot = try await db.collection("users").getDocuments() + + // Map documents to the User struct automatically + let users = querySnapshot.documents.compactMap { document in + try? document.data(as: User.self) + } + + for user in users { + print("Found user: \(user.firstName) \(user.lastName)") + } +} catch { + print("Error getting documents: \(error)") +} +``` + +## 5. Realtime Listeners in SwiftUI (Lifecycle Best Practices) + +When implementing Firestore realtime listeners (`addSnapshotListener`) within a +SwiftUI application, you **MUST** tie the listener lifecycle to the view's +identity using `.task(id:)`, NOT `.onDisappear`. + +### ⛔️ UNSAFE PATTERN (.onDisappear) + +Presenting a `.sheet` or `.fullScreenCover` can trigger the underlying view's +`onDisappear` method. If you stop your listener here, the feed will stop +updating while the sheet is open, and won't resume when it's dismissed. + +### ✅ SAFE PATTERN (.task with deinit) + +Because `addSnapshotListener` is a synchronous call, placing it inside a `.task` +means the task completes immediately. This breaks SwiftUI's automatic +cancellation mechanism. + +To safely manage traditional Firebase listeners in SwiftUI, you must use +**`deinit`** to handle memory cleanup when the view is destroyed, and +**`.task(id:)`** to handle data identity changes while the view is active. + +```swift +import SwiftUI +import FirebaseFirestore + +@MainActor +@Observable +final class DataManager { + private var listenerHandle: ListenerRegistration? + var data: [String] = [] + + func startListening(for userId: String) { + // 1. Clean up any existing listener to prevent duplicates if the ID changes + stopListening() + + // 2. Start the regular listener and capture the handle + listenerHandle = Firestore.firestore().collection("users").document(userId).addSnapshotListener { snapshot, error in + // Handle updates + } + } + + func stopListening() { + listenerHandle?.remove() + listenerHandle = nil + } + + // 3. Guarantee cleanup when the View is destroyed and this object is deallocated + isolated deinit { + stopListening() + } +} +``` + +Then, in your SwiftUI View, trigger the listener using `.task(id:)`. + +```swift +struct MyView: View { + @State private var manager = DataManager() + @Environment(AuthManager.self) var authManager + + var body: some View { + List(manager.data, id: \.self) { item in + Text(item) + } + // .task(id:) automatically re-runs if the userId changes. + // The view model handles stopping the old listener and starting the new one. + .task(id: authManager.userId) { + if let userId = authManager.userId { + manager.startListening(for: userId) + } else { + manager.stopListening() + } + } + } +} +``` diff --git a/.agents/skills/firebase-firestore/references/standard/provisioning.md b/.agents/skills/firebase-firestore/references/standard/provisioning.md new file mode 100644 index 0000000..5179f45 --- /dev/null +++ b/.agents/skills/firebase-firestore/references/standard/provisioning.md @@ -0,0 +1,110 @@ +# Provisioning Cloud Firestore + +## Manual Initialization + +Initialize the following firebase configuration files manually. Do not use +`npx -y firebase-tools@latest init`, as it expects interactive inputs. + +1. **Create `firebase.json`**: This file configures the Firebase CLI. +1. **Create `firestore.rules`**: This file contains your security rules. +1. **Create `firestore.indexes.json`**: This file contains your index + definitions. + +### 1. Create `firebase.json` + +Create a file named `firebase.json` in your project root with the following +content. If this file already exists, instead append to the existing JSON: + +```json +{ + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + } +} +``` + +This will use the default database with the Standard edition. To use a different +database, specify the database ID and location: + +1. Run `npx -y firebase-tools@latest firestore:locations` to get the list of + locations. +1. Ask the user which location to use, suggesting colocation if other parts of + the app already have a region selected. + +You can check the list of available databases using +`npx -y firebase-tools@latest firestore:databases:list`. + +If the database does not exist, it will be created when you deploy with the +specified configuration: + +```json +{ + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json", + "database": "my-database-id", + "location": "" + } +} +``` + +### 2. Create `firestore.rules` + +Create a file named `firestore.rules`. A good starting point (locking down the +database) is: + +``` +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + match /{document=**} { + allow read, write: if false; + } + } +} +``` + +*See the `firestore-rules-creation` skill for how to write actual rules.* + +### 3. Create `firestore.indexes.json` + +Create a file named `firestore.indexes.json` with an empty configuration to +start: + +```json +{ + "indexes": [], + "fieldOverrides": [] +} +``` + +*See [indexes.md](indexes.md) for how to configure indexes.* + +## Deploy database, rules and indexes + +**CRITICAL**: You MUST deploy the firestore configuration for the database to be +provisioned in the cloud and for your rules/indexes to take effect. If you don't +run this, your database will not exist. + +```bash +# To deploy all rules and indexes +npx -y firebase-tools@latest deploy --only firestore + +# To deploy just rules +npx -y firebase-tools@latest deploy --only firestore:rules + +# To deploy just indexes +npx -y firebase-tools@latest deploy --only firestore:indexes +``` + +## Local Emulation + +To run Firestore locally for development and testing: + +```bash +npx -y firebase-tools@latest emulators:start --only firestore +``` + +This starts the Firestore emulator, typically on port 8080. You can interact +with it using the Emulator UI (usually at http://localhost:4000/firestore). diff --git a/.agents/skills/firebase-firestore/references/standard/web_sdk_usage.md b/.agents/skills/firebase-firestore/references/standard/web_sdk_usage.md new file mode 100644 index 0000000..7b9007d --- /dev/null +++ b/.agents/skills/firebase-firestore/references/standard/web_sdk_usage.md @@ -0,0 +1,192 @@ +# Firestore Web SDK Usage Guide + +This guide focuses on the **Modular Web SDK** (v9+), which is tree-shakeable and +efficient. + +## Initialization + +```javascript +import { initializeApp } from "firebase/app"; +import { getFirestore } from "firebase/firestore"; + +// If running in Firebase App Hosting, you can skip Firebase Config and instead use: +// const app = initializeApp(); + +const firebaseConfig = { + // Your config options. Get the values by running 'npx -y firebase-tools@latest apps:sdkconfig ' +}; + +const app = initializeApp(firebaseConfig); +const db = getFirestore(app); + +``` + +## Writing Data + +### Set a Document (`setDoc`) + +Creates a document if it doesn't exist, or overwrites it if it does. + +```javascript +import { doc, setDoc } from "firebase/firestore"; + +// Create/Overwrite document with ID "LA" +await setDoc(doc(db, "cities", "LA"), { + name: "Los Angeles", + state: "CA", + country: "USA" +}); + +// To merge with existing data instead of overwriting: +await setDoc(doc(db, "cities", "LA"), { population: 3900000 }, { merge: true }); +``` + +### Add a Document with Auto-ID (`addDoc`) + +Use when you don't care about the document ID. + +```javascript +import { collection, addDoc } from "firebase/firestore"; + +const docRef = await addDoc(collection(db, "cities"), { + name: "Tokyo", + country: "Japan" +}); +console.log("Document written with ID: ", docRef.id); +``` + +### Update a Document (`updateDoc`) + +Update some fields of an existing document without overwriting the entire +document. Fails if the document doesn't exist. + +```javascript +import { doc, updateDoc } from "firebase/firestore"; + +const laRef = doc(db, "cities", "LA"); + +await updateDoc(laRef, { + capital: true +}); +``` + +### Transactions + +Perform an atomic read-modify-write operation. + +```javascript +import { runTransaction, doc } from "firebase/firestore"; + +const sfDocRef = doc(db, "cities", "SF"); + +try { + await runTransaction(db, async (transaction) => { + const sfDoc = await transaction.get(sfDocRef); + if (!sfDoc.exists()) { + throw "Document does not exist!"; + } + + const newPopulation = sfDoc.data().population + 1; + transaction.update(sfDocRef, { population: newPopulation }); + }); + console.log("Transaction successfully committed!"); +} catch (e) { + console.log("Transaction failed: ", e); +} +``` + +## Reading Data + +### Get a Single Document (`getDoc`) + +```javascript +import { doc, getDoc } from "firebase/firestore"; + +const docRef = doc(db, "cities", "SF"); +const docSnap = await getDoc(docRef); + +if (docSnap.exists()) { + console.log("Document data:", docSnap.data()); +} else { + console.log("No such document!"); +} +``` + +### Get Multiple Documents (`getDocs`) + +Fetches all documents in a query or collection once. + +```javascript +import { collection, getDocs } from "firebase/firestore"; + +const querySnapshot = await getDocs(collection(db, "cities")); +querySnapshot.forEach((doc) => { + // doc.data() is never undefined for query doc snapshots + console.log(doc.id, " => ", doc.data()); +}); +``` + +## Realtime Updates + +### Listen to a Document/Query (`onSnapshot`) + +```javascript +import { doc, onSnapshot } from "firebase/firestore"; + +const unsub = onSnapshot(doc(db, "cities", "SF"), (doc) => { + console.log("Current data: ", doc.data()); +}); + +// Stop listening +// unsub(); +``` + +### Handle Changes (Added/Modified/Removed) + +```javascript +import { collection, query, where, onSnapshot } from "firebase/firestore"; + +const q = query(collection(db, "cities"), where("state", "==", "CA")); +const unsubscribe = onSnapshot(q, (snapshot) => { + snapshot.docChanges().forEach((change) => { + if (change.type === "added") { + console.log("New city: ", change.doc.data()); + } + if (change.type === "modified") { + console.log("Modified city: ", change.doc.data()); + } + if (change.type === "removed") { + console.log("Removed city: ", change.doc.data()); + } + }); +}); +``` + +## Queries + +### Simple and Compound Queries + +Use `query()` to combine filters. + +```javascript +import { collection, query, where, getDocs } from "firebase/firestore"; + +const citiesRef = collection(db, "cities"); + +// Simple equality +const q1 = query(citiesRef, where("state", "==", "CA")); + +// Compound (AND) +// Note: Requires an index if filtering on different fields +const q2 = query(citiesRef, where("state", "==", "CA"), where("population", ">", 1000000)); +``` + +### Order and Limit + +Sort and limit results. + +```javascript +import { orderBy, limit } from "firebase/firestore"; + +const q = query(citiesRef, orderBy("name"), limit(3)); +``` diff --git a/.agents/skills/firebase-security-rules-auditor/SKILL.md b/.agents/skills/firebase-security-rules-auditor/SKILL.md new file mode 100644 index 0000000..18d1aba --- /dev/null +++ b/.agents/skills/firebase-security-rules-auditor/SKILL.md @@ -0,0 +1,85 @@ +--- +name: firebase-security-rules-auditor +description: >- + Audits Firebase (Firestore, Cloud Storage) security rules for vulnerabilities, privilege escalation, role bypasses, create vs update inconsistencies, resource exhaustion, type safety, size limits, and hasOnly ownership checks. Use when auditing/reviewing rules, running red-team rule assessments, or scoring against auditor checklists. Don't use for Firebase CLI (login, deploy), Auth, Crashlytics, Remote Config, or database queries. +metadata: + author: Google LLC + category: CloudSecurity +--- + +# Overview + +This skill acts as an auditor for Firebase Security Rules, evaluating them +against a rigorous set of criteria to ensure they are secure, robust, and +correctly implemented. + +# Scoring Criteria + +## Assessment: Security Validator (Red Team Edition) + +You are a Senior Security Auditor and Penetration Tester specializing in +Firestore. Your goal is to find "the hole in the wall." Do not assume a rule is +secure because it looks complex; instead, actively try to find a sequence of +operations to bypass it. + +### Mandatory Audit Checklist: + +1. **The Update Bypass:** Compare 'create' and 'update' rules. Can a user create + a valid document and then 'update' it into an invalid or malicious state + (e.g., changing their role, bypassing size limits, or corrupting data types)? +1. **Authority Source:** Does the security rely on user-provided data + (request.resource.data) for sensitive fields like 'role', 'isAdmin', or + 'ownerId'? Carefully consider the source for that authority. +1. **Business Logic vs. Rules:** Does the rule set actually support the app's + purpose? (e.g., In a collaboration app, can collaborators actually read the + data? If not, the rules are "broken" or will force insecure workarounds). +1. **Storage Abuse:** Are there string length or array size limits? If not, + label it as a "Resource Exhaustion/DoS" risk. +1. **Type Safety:** Are fields checked with 'is string', 'is int', or 'is + timestamp'? +1. **Field-Level vs. Identity-Level Security:** Be careful with rules that use + `hasOnly()` or `diff()`. While these restrict *which* fields can be updated, + they do NOT restrict *who* can update them unless an ownership check (e.g., + `resource.data.uid == request.auth.uid`) is also present. If a rule allows + any authenticated user to update fields on another user's document without a + corresponding ownership check, it is a data integrity vulnerability. + +### Admin Bootstrapping & Privileges: + +The admin bootstrapping process is limited in this app. If the rules use a +single hardcoded admin email (e.g., checking request.auth.token.email == +'admin@example.com'), this should NOT count against the score as long as: + +- email_verified is also checked (request.auth.token.email_verified == true). +- It is implemented in a way that does not allow additional admins to add + themselves or leave an escalation risk open. + +### Scoring Criteria (1-5): + +- **1 (Critical):** Unauthorized data access (leaks), privilege escalation, or + total validation bypass. +- **2 (Major):** Broken business logic, self-assigned roles, bypass of controls. +- **3 (Moderate):** PII exposure (e.g., public emails), Inconsistent validation + (create vs update) on critical fields +- **4 (Minor):** Problems that result in self-data corruption like update + bypasses that only impact the user's own data, lack of size limits, missing + minor type checks or over-permissive read access on non-sensitive fields. +- **5 (Secure):** Comprehensive validation, strict ownership, and role-based + access via secure ACLs. + +Return your assessment in JSON format using the following structure: + +```json +{ + "score": 1, + "summary": "overall assessment", + "findings": [ + { + "check": "checklist item", + "severity": "critical|major|moderate|minor", + "issue": "description", + "recommendation": "fix" + } + ] +} +``` diff --git a/.agents/skills/firestore-rules-creation/SKILL.md b/.agents/skills/firestore-rules-creation/SKILL.md new file mode 100644 index 0000000..a6632bc --- /dev/null +++ b/.agents/skills/firestore-rules-creation/SKILL.md @@ -0,0 +1,600 @@ +--- +name: firestore-rules-creation +description: >- + Designs, authors, refactors, and hardens production-grade Cloud Firestore Security Rules (firestore.rules). IMPORTANT: If subagent delegation AND the firestore-rules-author subagent are available in your environment, delegate authoring firestore.rules to the firestore-rules-author subagent. If subagent delegation is unavailable (e.g. not enabled in the IDE), firestore-rules-author is not installed, or you are running inside firestore-rules-author, follow this skill directly. +metadata: + author: Google LLC + category: Databases +--- + +# Firestore Security Rules Creation + +> [!IMPORTANT] **Subagent Delegation vs. Direct Authoring** +> +> - **If subagent delegation AND the `firestore-rules-author` subagent are +> available** in your active environment: **DO NOT** author `firestore.rules` +> directly in the main agent. Invoke the `firestore-rules-author` subagent and +> delegate the analysis and creation of `firestore.rules` to it. +> - **If subagent delegation is unavailable** (e.g., in IDE environments where +> subagent tools are not enabled) **OR the `firestore-rules-author` subagent +> is not installed**, **OR if you are already executing inside +> `firestore-rules-author`**: proceed with the workflow below to author +> `firestore.rules` directly. + +You are an expert Firebase Security Rules engineer with deep knowledge of +Firestore security best practices. Your task is to generate comprehensive, +secure Firebase Security rules for the user's project. To minimize the risk of +security incidents and avoid misleading the user about the security of their +application, you must be extremely humble about the rules you generate. Always +present the rules you've written as a prototype that needs review. + +After generating the rules, you MUST explicitly communicate to the user exactly +like this: "I've set up prototype Security Rules to keep the data in Firestore +safe. They are designed to be secure for . However, you +should review and verify them before broadly sharing your app. If you'd like, I +can help you harden these rules." + +### Workflow + +Follow this structured workflow strictly: + +#### Phase-1: Codebase Analysis + +1. **Scan the entire codebase** to identify: + - Programming language(s) used (for understanding context only) + - All Firestore collection and document paths + - **All Firestore Queries:** Identify every `where()`, `orderBy()`, and + `limit()` clause. The security rules **MUST** allow these specific queries. + - Data models and schemas (interfaces, classes, types) + - Data types for each field (strings, numbers, booleans, timestamps, URLs, + emails, etc.) + - Required vs. optional fields + - Field constraints (min/max length, format patterns, allowed values) + - CRUD operations (create, read, update, delete) + - Authentication patterns (Firebase Auth, custom tokens, anonymous) + - Access patterns and business logic rules +1. **Synthesize your findings** concisely (in memory; do not create extra + untracked scratch files in the workspace) and refer to them when generating + the security rules. + +#### Phase-2: Security Rules Generation + +**CRITICAL**: Follow the following principles **every time you modify the +security rules file** + +Generate Firebase Security Rules following these principles: + +- **Default deny:** Start with denying all access, then explicitly allow only + what's needed +- **Least privilege:** Grant minimum permissions required +- **Validate data:** Check data types, allowed fields, and constraints on both + creates and updates. + - **MANDATORY:** You **MUST** use the **Validator Function Pattern** described + in the "Critical Directives" section below. This involves defining a + specific validation function (e.g., `isValidUser`) and calling it in + **BOTH** `create` and `update` rules. + - **MANDATORY:** For **ALL** creates **AND ALL** updates, ensure that after + the operation, the required fields are still available and that the data is + valid. +- **Authentication checks:** Verify user identity before granting access +- **Authorization logic:** Implement role-based or ownership-based access + control +- **UID Protection:** Prevent users from changing ownership of data +- **Initially restricted:** Never make any collection or data publicly readable, + always require authentication for any access to data unless the user makes an + *explicit* request for unauthenticated data. + +This means the first firestore.rules file you generate must never have any +"allow read: true" statements. + +**Structure Requirements:** + +1. **Document assumed data models at the beginning of the rules file:** + +```javascript +// =============================================================== +// Assumed Data Model +// =============================================================== +// +// This security rules file assumes the following data structures: +// +// Collection: [name] +// Document ID: [pattern] +// Fields: +// - field1: type (required/optional, constraints) - description +// - field2: type (required/optional, constraints) - description +// [List all fields with types, constraints, and whether immutable] +// +// [Repeat for all collections] +// +// =============================================================== +``` + +1. **Include comprehensive helper functions to avoid repetition:** + +```javascript +// =============================================================== +// Helper Functions +// =============================================================== +// +// Check if the user is authenticated +function isAuthenticated() { + return request.auth != null; +} +// +// Check if user owns the resource (for user-owned documents) +function isOwner(userId) { + return isAuthenticated() && request.auth.uid == userId; +} +// +// Check if user is owner based on document's uid field +function isDocOwner() { + return isAuthenticated() && request.auth.uid == resource.data.uid; +} +// +// Verify UID hasn't been tampered with on create +function uidUnchanged() { + return !('uid' in request.resource.data) || + request.resource.data.uid == request.auth.uid; +} +// +// Ensure uid field is not modified on update +function uidNotModified() { + return !('uid' in request.resource.data) || + request.resource.data.uid == resource.data.uid; +} +// +// Validate required fields exist +function hasRequiredFields(fields) { + return request.resource.data.keys().hasAll(fields); +} +// +// Validate string length +function validStringLength(field, minLen, maxLen) { + return request.resource.data[field] is string && + request.resource.data[field].size() >= minLen && + request.resource.data[field].size() <= maxLen; +} +// +// Validate URL format (must start with https:// or http://) +function isValidUrl(url) { + return url is string && + (url.matches("^https://.*") || url.matches("^http://.*")); +} +// +// Validate email format +function isValidEmail(email) { + return email is string && + email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); +} + +// +// Validate ISO 8601 date string format (YYYY-MM-DDTHH:MM:SS) +// CRITICAL: This validates format ONLY, not logical date values (e.g., month 13). +// Use the 'timestamp' type for documents where logical date validation is required. +function isValidDateString(dateStr) { + return dateStr is string && + dateStr.matches("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}.*Z?$"); +} + +// +// Validate that a string path is correctly scoped to the user's ID +function isScopedPath(path) { + return path is string && path.matches("^users/" + request.auth.uid + "/.*"); +} +// +// Validate that a value is positive +function isPositive(field) { + return request.resource.data[field] is number && request.resource.data[field] > 0; +} +// +// Validate that a list is a list and enforces size limits +function isValidList(list, maxSize) { + return list is list && list.size() <= maxSize; +} +// +// Validate optional string (if present, must be string and within length) +function isValidOptionalString(field, minLen, maxLen) { + return !(field in request.resource.data) || + (request.resource.data[field] is string && + request.resource.data[field].size() >= minLen && + request.resource.data[field].size() <= maxLen); +} +// +// Validate that a map contains only allowed keys +function isValidMap(mapData, allowedKeys) { + return mapData is map && mapData.keys().hasOnly(allowedKeys); +} +// +// Validate that the document contains only the allowed fields +function hasOnlyAllowedFields(fields) { + return request.resource.data.keys().hasOnly(fields); +} +// +// Validate that the document hasn't changed in the fields that are not allowed to be changed +function areImmutableFieldsUnchanged(fields) { + return !request.resource.data.diff(resource.data).affectedKeys().hasAny(fields); +} +// +// Validate that a timestamp is recent (within the last 5 minutes) +function isRecent(time) { + return time is timestamp && + time > request.time - duration.value(5, 'm') && + time <= request.time; +} +// +// [Add more helper functions as needed for the data validation like the example below] +// +// =============================================================== +// +// Domain Validators (CRITICAL: Use these in both create and update) +// +// function isValidUser(data) { +// // Only allow admin to create admin roles +// return hasOnlyAllowedFields(['name', 'email', 'age', 'role']) && +// data.name is string && data.name.size() > 0 && data.name.size() < 50 && +// data.email is string && isValidEmail(data.email) && +// data.age is number && data.age >= 18 && +// data.role in ['admin', 'user', 'guest']; +// } +``` + +#### Mandatory: User Data Separation (The "No Mixed Content" Rule) + +- Firestore security rules apply to the entire document. You cannot allow users + to read the displayName field while hiding the email field in the same + document. +- If a collection (e.g., users) contains ANY PII (email, phone, address, private + settings), you MUST strictly limit read access to the document owner only + (allow read: if isOwner(userId);). +- If the application requires public profiles (e.g., showing user names/avatars + on posts): + - 1. Denormalization (Preferred): Copy the user's public info (name, photoURL) + directly onto the resources they create (e.g., store authorName and + authorPhoto inside the posts document). + - 2. Split Collections: Create a separate users_public collection that + contains only non-sensitive data, and keep the sensitive data in a + locked-down users_private collection. +- NEVER write a rule that allows read access to a document containing PII for + anyone other than the owner. + +#### **CRITICAL** RBAC Guidelines + +This is one of the most important set of instructions to follow. Failing to +follow these rules will result in catastrophic security vulnerabilities. + +- **NEVER** allow users to create their own privileged roles. That means that no + user should be able to create an item in a database with their role set to a + role similar to "admin" unless they are already a bootstrapped admin. +- **NEVER** allow users to update their own roles or permissions. +- **NEVER** allow users to grant themselves access to other users' data. +- **NEVER** allow users to bypass the role hierarchy. +- **ALWAYS** validate that the user is authorized to perform the requested + action. +- **ALWAYS** validate that the user is not attempting to escalate their + privileges. +- **ALWAYS** validate that the user is not attempting to access data they do not + have permission to access. + +Here's a **bad** example of what **NOT** to do: + +```javascript +match /users/{userId} { + // BAD: Allows users to create their own roles because a user can create a new user document with a role of 'admin' and the isAdmin() function will return true + allow create: if (isOwner(userId) && isValidUser(request.resource.data)) || isAdmin(); + // BAD: Allows users to update their own roles because a user can update their own user document with a role of 'admin' and the isAdmin() function will return true + allow update: if (isOwner(userId) && isValidUser(request.resource.data)) || isAdmin(); +} +``` + +Here's a **good** example of what **TO** do: + +```javascript +match /users/{userId} { + // GOOD: Does NOT allow users to create their own roles unless they are an admin or the user is updating their own role to a less privileged role + allow create: if isAuthenticated() && isValidUser(request.resource.data) && ((isOwner(userId) && request.resource.data.role == 'client') || isAdmin()); + // GOOD: Does NOT allow users to update their own roles unless they are an admin + allow update: if isAuthenticated() && isValidUser(request.resource.data) && ((isOwner(userId) && request.resource.data.role == resource.data.role) || isAdmin()); +} +``` + +#### Critical Directives for Secure Generation + +- **PREFER USING READ OVER LIST OR GET** `list` and `get` can add complexity to + security rules. Prefer using `read` over them. + +- **Date and Timestamp Validation:** + + - **Prefer Timestamps:** ALWAYS prefer the `timestamp` type for date fields. + Firestore automatically ensures they are logically valid dates. + - **String Date Risks:** If using strings for dates (e.g., ISO 8601), a regex + check like `isValidDateString` only validates **format**, not **logic** (it + would accept Feb 31st). + - **Regex Escaping:** When using regex for digits, you **MUST** use double + backslashes (e.g., `\\\\d`) in the rules string. Using a single backslash + (`\\d`) is a common bug that causes validation to fail. + +- **Immutable Fields:** Fields like `createdAt`, `authorUID`, or any other field + that should not change after creation must be explicitly protected in `update` + rules. (e.g., `request.resource.data.createdAt == resource.data.createdAt`). + **CRITICAL**: When allowing non-owners to update specific fields (like + incrementing a counter), you **MUST** explicitly verify that all other fields + (e.g., `authorName`, `tags`, `body`) remain unchanged to prevent unauthorized + metadata modification. For sensitive fields, ensure that the logged in user is + also the owner of the document. + +- **Identity Integrity:** When storing denormalized user identity (e.g. + `authorName`, `authorPhoto`), you **MUST** validate this data. + + - **Prefer Auth Token:** If possible, check if + `request.resource.data.authorName == request.auth.token.name`. + - **Strict Validation:** If the auth token is unavailable, you **MUST** + strictly validate the type (string) and length (e.g. < 50 chars) to prevent + spoofing with massive or malicious payloads. + - **Client-Side Fetching:** The most secure pattern is to store ONLY + `authorUid` and fetch the profile client-side. If you denormalize, you + accept the risk of stale or spoofed data unless you validate it. + +- **Enforce Strict Schema (No Extraneous Fields):** Documents must not contain + any fields other than those explicitly defined in the data model. This + prevents users from adding arbitrary data. + +- **NEVER allow PII EXPOSURE LEAKS:** Never allow PII (Personally Identifiable + Information) to be exposed in the data model. This includes email addresses, + phone numbers, and any other information that could be used to identify a + user. For example, even if a user is logged-in, they should not have access to + read another user's information. + +- **No Blanket User Read Access:** You are strictly FORBIDDEN from generating + `allow read: if isAuthenticated();` for the users collection if that + collection is defined to contain email addresses or other private data. + +- **CRITICAL: Double-Check Blanket `isAuthenticated` fields:** Ensure that paths + that are protected with only `isAuthenticated()` do not need any additional + checks based on role or any other condition. + +- **The "Ownership-Only Update" Trap:** A common critical vulnerability is + allowing updates based solely on ownership (e.g., + `allow update: if isOwner(resource.data.uid);`). This allows the owner to + corrupt the data schema, delete required fields, or inject malicious payloads. + You **MUST** always combine ownership checks with data validation (e.g., + `allow update: if isOwner(...) && isValidEntity(...);`) **AND** validate that + self-escalation is not possible. + +- **Deep Array Inspection:** It is insufficient to check if a field `is list`. + You **MUST** validate the contents of the array (e.g., ensuring all elements + are strings of a valid UID length) to prevent data corruption or schema + pollution. For example, a `tags` array must verify that every item is a string + AND that each string is within a reasonable length (e.g., < 20 chars). + +- **Permission-Field Lockdown:** Fields that control access (e.g., `editors`, + `viewers`, `roles`, `role`, `ownerId`) **MUST** be immutable for non-owner + editors. In `update` rules, use `areImmutableFieldsUnchanged()` for these + fields unless the `request.auth.uid` matches the document's original + owner/creator. This prevents "Permission Escalation" where a collaborator + could grant themselves higher privileges or remove the owner. + +### Advanced Validation for Business Logic + +Secure rules must enforce the application's business logic. This includes +validating field values against a list of allowed options and controlling how +and when fields can change. + +#### 1. Enforce Enum Values + +If a field should only contain specific values (e.g., a status), validate +against a list. + +**Example:** + +```javascript + // A 'task' document's status can only be one of three values + function isValidStatus() { + let validStatuses = ['pending', 'in-progress', 'completed']; + return request.resource.data.status in validStatuses; + } + + allow create: if isValidStatus() && ... +``` + +#### 2. Validate State Transitions + +For `update` operations, you **MUST** validate that a field is changing from a +valid previous state to a valid new state. This prevents users from bypassing +workflows (e.g., marking a task as 'completed' from 'archived'). + +**Example:** + +```javascript + // A task can only be marked 'completed' if it was 'in-progress' + function validStatusTransition() { + let previousStatus = resource.data.status; + let newStatus = request.resource.data.status; + + return (previousStatus == 'in-progress' && newStatus == 'completed') || + (previousStatus == 'pending' && newStatus == 'in-progress'); + } + + allow update: if validStatusTransition() && ... +``` + +#### 3. Strict Path and Relationship Scoping + +For any field that references another resource (like an image path or a parent +document ID), you **MUST** ensure it is correctly scoped to the user or valid +within the context. + +**Example:** + +```javascript +// Ensure image path is within the user's own storage folder +allow create: if isScopedPath(request.resource.data.imageBucket) && ... +``` + +#### 4. Secure Counter Updates + +When allowing users to update a counter (like `voteCount` or `answerCount`), you +**MUST** ensure: 1. **Atomic Increments:** The field is only changing by exactly ++1 or -1. 2. **Isolation:** **NO OTHER FIELDS** are being modified. This is +critical to prevent attackers from hijacking the `authorName` or `content` while +"voting". 3. **Action Verification:** You **MUST** prevent users from +artificially inflating counts. When incrementing a counter, verify that the user +has not already performed the action (e.g., by checking for the existence of a +'like' document) and is not looping updates. * **CRITICAL:** Relying solely on +`!exists(likeDoc)` is insufficient because a malicious user can skip creating +the document and loop the increment. * **SOLUTION:** Use `getAfter()` to verify +that the corresponding tracking document *will exist* after the batch completes. + +**Example:** + +```javascript +function isValidCounterUpdate(docId) { + // Allow update only if 'voteCount' is the ONLY field changing + return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['voteCount']) && + // And the change is exactly +1 or -1 + math.abs(request.resource.data.voteCount - resource.data.voteCount) == 1 && + // Verify consistency: + ( + // Increment: Vote must NOT exist before, but MUST exist after + (request.resource.data.voteCount > resource.data.voteCount && + !exists(/databases/$(database)/documents/votes/$(request.auth.uid + '_' + docId)) && + getAfter(/databases/$(database)/documents/votes/$(request.auth.uid + '_' + docId)) != null) || + // Decrement: Vote MUST exist before, but must NOT exist after + (request.resource.data.voteCount < resource.data.voteCount && + exists(/databases/$(database)/documents/votes/$(request.auth.uid + '_' + docId)) && + getAfter(/databases/$(database)/documents/votes/$(request.auth.uid + '_' + docId)) == null) + ); +} + +allow update: if isValidCounterUpdate(docId) && ... +``` + +#### 5. **CRITICAL** Ensure Application Validity + +While updating the firestore rules, also ensure that the application still works +after firestore rules updates. + +1. **For each collection, implement explicit data validation:** + +- Type Checking: 'field is string', 'field is number', 'field is bool', 'field + is timestamp' +- Required fields validation using 'hasRequiredFields()' +- **Enforce Size Limits:** For **EVERY** string, list, and map field, you + **MUST** enforce realistic size limits (e.g., `text.size() < 1000`, + `tags.size() < 20`). **Failure to limit a single string field (like `caption` + or `bio`) allows 1MB attacks, which is a CRITICAL vulnerability.** +- URL validation using 'isValidUrl()' for URL fields +- Email validation using 'isValidEmail()' for email fields +- **Immutable field protection** (authorId, createdAt, etc. should not change on + update) +- **UID protection** using 'uidUnchanged()' on creates and 'uidNotModified()' on + updates should be accompanied with `isDocOwner()` +- **Temporal accuracy** using `isRecent()` for timestamps. +- **Range validation** using `isPositive()` or similar for numbers. +- **Path scoping** using `isScopedPath()` for storage paths. + +Structure your rules clearly with comments explaining each rule's purpose. + +#### Phase-3: Devil's Advocate Attack + +**Critical step:** Systematically attempt to break your own rules using the +following attack vectors. You MUST document the outcome of each attempt. + +1. **Public List Exploit:** Can I run a collection query without authentication + and retrieve documents that should be private (e.g., where + `visible == false`)? +1. **Unauthorized Read/Write:** Can I `get`, `create`, `update`, or `delete` a + document that I do not own or have permissions for? +1. **The "Update Bypass":** Can I `create` a valid document and then `update` it + with a 1MB string or invalid fields? (Tests if validation logic is missing + from `update`). +1. **Ownership Hijacking (Create):** Can I create a document and set the + `authorUID` or `ownerId` to another user's ID? +1. **Ownership Hijacking (Update):** Can I `update` an existing document to + change its `authorUID` or `ownerId`? +1. **Immutable Field Modification:** Can I change a `createdAt` or other + immutable timestamp or property on an `update`? +1. **Data Corruption (Type Juggling):** Can I write a `number` to a field that + should be a `string`, or a `string` to a `timestamp`? +1. **Validation Bypass (Create vs. Update):** Can I `create` a valid document + and then `update` it into an invalid state (e.g., remove a required field, + write a string that's too long)? +1. **Resource Exhaustion / DoS:** Can I write an enormous string (e.g., 1MB) to + any field that accepts a string or a massive array to a list field? Every + string field (e.g., `bio`, `url`, `name`) MUST have a `.size()` check. If any + are missing, it's a "Resource Exhaustion/DoS" risk. +1. **Required Field Omission:** Can I `create` or `update` a document while + omitting fields that are marked as required in the data model? +1. **Privilege Escalation:** Can I create an account and assign myself an admin + role by writing `isAdmin: true` to my user profile document? (Tests reliance + on document data vs. custom claims). +1. **Schema Pollution:** Can I `create` or `update` a document and add an + arbitrary, undefined field like `extraData: 'malicious_code'`? (Tests for + strict schema enforcement). +1. **Invalid State Transition:** Can I update a document's `status` field from + `'pending'` directly to `'completed'`, bypassing the required `'in-progress'` + state? (Tests business logic enforcement). +1. **Path Traversal / Scoping Attack:** Can I set a path field (like + `imageBucket` or `profilePic`) to a value that points to another user's data + or a restricted area? (Tests for regex path scoping). +1. **Timestamp Manipulation:** Can I set a `createdAt` field to the past or + future to bypass sorting or logic? (Tests for `request.time` validation). +1. **Negative Value / Overflow:** Can I set a numeric field (like `price` or + `quantity`) to a negative number or an extremely large one? (Tests for range + validation). +1. **The "Mixed Content" Leak:** Create a second user. Can User B read User A's + users document? If "Yes" (because you wanted public profiles), does that + document also contain User A's email or private keys? If both are true, the + rules are insecure. +1. **Counter/Action Replay:** If there is a counter (like `likesCount`), can I + increment it without creating the corresponding tracking document (e.g., + inside `likes/{userId}`)? Can I increment it twice? (Tests for `getAfter()` + consistency checks). +1. **Orphaned Subcollection Access:** Can I read/write to a subcollection (e.g., + `users/123/posts/456`) if the parent document (`users/123`) does not exist? + (Tests for parent existence checks). +1. **Query Mismatch:** Do the rules actually allow the queries the app performs? + (e.g., if the app filters by `status == 'published'`, do the rules allow + `list` only when `resource.data.status == 'published'`?) +1. **Validator Pattern Check:** Do **ALL** `update` rules (including owner-only + ones) call the `isValidX()` function? If an `allow update` rule only checks + `isOwner()`, it is a CRITICAL vulnerability. + +Evaluate each attack attempt mentally (do not create separate untracked +attack-log files). If ANY attack succeeds: + +- Fix the security hole +- Regenerate the rules +- **Repeat Phase-3** until no attacks succeed + +#### Phase-4: Syntactic Validation + +Once devil's advocate testing passes, repeat until rules pass validation. + +**After all phases are complete, create or update the `firestore.rules` file.** + +### Critical Constraints + +1. **Never skip the devil's advocate phase** - this is your primary security + validation +1. **MUST include helper functions** for common operations ('isAuthenticated', + 'isOwner', 'uidUnchanged', 'uidNotModified') AND domain validators + ('isValidUser', etc.) +1. **MUST document assumed data models** at the beginning of the rules file +1. **Always validate the rules syntax** using 'firebase deploy --only + firestore:rules --dry-run' or a similar tool before outputting the final + file. +1. **Provide complete, runnable code** - no placeholders or TODOs +1. **Document all assumptions** about data structure or access patterns +1. **Always run the devil's advocate attack** after any modification of the + rules. +1. **Determine whether the rules need to be updated** after permission denied + errors occur. +1. **Do not make overly confident guarantees of the security of rules that you + have generated**. It is very difficult to exhaustively guarantee that there + are no vulnerabilities in a rules set, and it is vital to not mislead users + into thinking that their rules are perfect. After an initial rules + generation, you should describe the rules you've written as a solid + prototype, and tell users that before they launch their app to a large + audience, they should work with you to harden and validate the rules file. Be + clear that users should carefully review rules to ensure security. diff --git a/skills-lock.json b/skills-lock.json index 8fd02a1..3a23fc7 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -13,6 +13,24 @@ "skillPath": "skills/firebase-basics/SKILL.md", "computedHash": "68440f06d6d608d0551ae6d1830e2ea86eb52acbe9e43de95775d68c88b6e79b" }, + "firebase-firestore": { + "source": "firebase/agent-skills", + "sourceType": "github", + "skillPath": "skills/firebase-firestore/SKILL.md", + "computedHash": "462374260347e515548d6c5845c1777da677e2b221f179a7add1b32fa58aae47" + }, + "firebase-security-rules-auditor": { + "source": "firebase/agent-skills", + "sourceType": "github", + "skillPath": "skills/firebase-security-rules-auditor/SKILL.md", + "computedHash": "c910ebaa427236f3b6d5e72112c85c44676dc9046787988dbd07c5573cca7f1b" + }, + "firestore-rules-creation": { + "source": "firebase/agent-skills", + "sourceType": "github", + "skillPath": "skills/firestore-rules-creation/SKILL.md", + "computedHash": "49f99748e1c7e41c055a1c3ba06f4afc6888bf534588401cd6fb55ecd09f22b4" + }, "next-dev-loop": { "source": "vercel/next.js", "sourceType": "github",