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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# firebase-emulator/Dockerfile COPYs nothing — the config it needs is bind-mounted at
# runtime by docker-compose.yml. Excluding everything keeps the build context empty so
# `docker compose build` does not stream node_modules to the daemon.
*
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# The emulator entrypoint is executed by /bin/sh inside a Linux container. If Git
# checks it out with CRLF on Windows, the shell fails on the carriage returns.
firebase-emulator/*.sh text eol=lf
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,7 @@ npm-debug.*

.env*
google-services.json
GoogleService-Info.plist
GoogleService-Info.plist

# Firebase emulator state written by `docker compose` (--export-on-exit)
firebase-emulator/data/
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,73 @@ Create an Internal build by running the following script and provide the link to
yarn dev-client-sim
```

## Local Firestore in Docker

A minimal Firebase Emulator Suite (Firestore + Auth) runs in a container, so you can
develop against a throwaway database instead of the production project. Docker is the
only prerequisite — no Java, no `firebase-tools`, and no credentials of any kind.

```
$ docker compose up # or: yarn emulators
```

| Service | URL |
| ------------ | ----------------------- |
| Emulator UI | <http://localhost:4000> |
| Firestore | `localhost:8080` |
| Auth | `localhost:9099` |

Two accounts are seeded automatically, both with the password `password123`:

| Email | Purpose |
| ------------------- | ------------------------------------------------------------ |
| `member@tamu.edu` | Has **no** `gender` field, so the gender prompt appears |
| `officer@tamu.edu` | Already answered the gender question, and has officer roles |

### Pointing the app at it

The app connects to the emulators only when `FIREBASE_EMULATOR_ADDRESS` is set (see
`src/config/firebaseConfig.ts`). Add this to your `.env`:

```
FIREBASE_EMULATOR_ADDRESS=127.0.0.1
FIREBASE_AUTH_PORT=9099
FIREBASE_FIRESTORE_PORT=8080
```

Then restart Metro with a cleared cache — these values are inlined at build time by
`babel-plugin-inline-dotenv`, so an already-running bundler will keep using the old ones:

```
$ npx expo start --dev-client --clear
```

**On a physical device, `127.0.0.1` is the phone, not your computer.** Use your machine's
LAN address instead (`ipconfig` on Windows, `ifconfig` on macOS), for example
`FIREBASE_EMULATOR_ADDRESS=192.168.1.42`. An Android emulator uses `10.0.2.2`.

Remove these three lines from `.env` to go back to the real backend.

### Data and rules

State persists between runs: it is exported to `firebase-emulator/data/` on shutdown
and re-imported on the next start. That directory is gitignored. To wipe it:

```
$ yarn emulators:reset
```

Because the export happens on `SIGINT`, stop the emulators with `docker compose down`
or `Ctrl-C` rather than killing the container, or the session's data is lost.

Rules come from `firebase-emulator/firestore.rules`, which is deliberately wide open
(`allow read, write: if true`) and does **not** mirror production. Edits to it hot-reload
in the running emulator, so this is not the place to test whether real rules permit a
write. The container only ever runs `emulators:start`, so it cannot deploy anything.

> The `functions` emulator is intentionally excluded to keep startup fast; add it to
> `--only` in `firebase-emulator/start.sh` if you need to work on Cloud Functions.

## Test - TODO: Need more details
TEMP LINK: https://github.com/TAMUSHPE/MobileApp/pull/378
```
Expand Down
71 changes: 71 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# `docker compose up` = Firebase Emulator Suite (Firestore + Auth) + seed data.
# No real credentials anywhere in this file — emulator-only by design.
#
# The Expo app itself is NOT containerised: run `npm start` on the host as usual and
# point it at these ports with the FIREBASE_* variables in .env (see README section
# "Local Firestore in Docker"). Nothing here can touch the production project; the
# container only ever runs `emulators:start`, never `firebase deploy`.
services:
emulators:
build:
context: .
dockerfile: firebase-emulator/Dockerfile
# Startup logic lives in a script, not inline here: a YAML folded scalar does not
# fold lines indented deeper than the first, so a multi-line inline command would
# silently split apart and drop every flag. Invoked via `sh` rather than executed
# directly, because the exec bit does not reliably survive a Windows bind mount.
command: sh ./firebase-emulator/start.sh
working_dir: /workspace
# Only the emulator's own config is mounted — not the repo. Bind-mounting the whole
# project would drag node_modules across the Windows filesystem boundary for no gain,
# since nothing in here imports the app's code.
# firebase-emulator/ is writable so --export-on-exit can persist state into the repo,
# and so edits to firestore.rules on the host hot-reload in the running emulator.
volumes:
- ./firebase.json:/workspace/firebase.json:ro
- ./.firebaserc:/workspace/.firebaserc:ro
- ./firebase-emulator:/workspace/firebase-emulator
ports:
- "4000:4000" # Emulator UI -> http://localhost:4000
- "4400:4400" # Emulator hub (the UI in your browser calls this directly)
- "4500:4500" # Logging
- "8080:8080" # Firestore
- "9099:9099" # Auth
# Firestore's root path answers 200. The Auth emulator's root may not, so this uses
# `curl -s` without -f there: any response at all proves the port is listening.
healthcheck:
test:
- CMD-SHELL
- >-
curl -sf http://localhost:8080/ >/dev/null
&& curl -s --connect-timeout 1 http://localhost:9099/ >/dev/null
interval: 2s
timeout: 5s
retries: 45
start_period: 5s
# firebase-tools flushes the export on SIGINT. Compose sends SIGTERM by default,
# which would kill it before it writes, silently losing everything you did.
stop_signal: SIGINT
stop_grace_period: 30s

# One-shot: populates fixtures, then exits. Safe to re-run — the script uses fixed
# document IDs and tolerates Auth users that already exist, so re-seeding just
# re-applies the same deterministic data on top of whatever was re-imported.
seed:
build:
context: .
dockerfile: firebase-emulator/Dockerfile
command: node firebase-emulator/seed.js
working_dir: /workspace
volumes:
- ./firebase-emulator:/workspace/firebase-emulator
environment:
# firebase-admin routes to the emulators (and skips credential lookup entirely)
# purely because these are set. There is no key file and none is needed.
FIRESTORE_EMULATOR_HOST: emulators:8080
FIREBASE_AUTH_EMULATOR_HOST: emulators:9099
GOOGLE_CLOUD_PROJECT: tamushpemobileapp
depends_on:
emulators:
condition: service_healthy
restart: "no"
34 changes: 34 additions & 0 deletions firebase-emulator/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Dev container for the Firebase Emulator Suite (Firestore + Auth).
#
# This image does NOT build or run the Expo app. Metro and the native runtime stay
# on the host; only the backend the app talks to lives in here. See docker-compose.yml.
#
# No secrets are baked in and no real credentials are ever needed: the emulators
# ignore API keys and accept unauthenticated admin access from inside the network.
FROM node:20-slim

# The Firestore emulator is a Java program; the Emulator UI and Auth emulator are not.
# curl is used by the compose healthcheck.
RUN apt-get update \
&& apt-get install -y --no-install-recommends default-jre-headless curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*

# firebase-tools runs the emulators. firebase-admin is only used by the seed script,
# which talks to the emulators over the *_EMULATOR_HOST variables and so needs no
# service-account key.
RUN npm install -g firebase-tools@13 firebase-admin@12

# Global installs are not on Node's default resolution path for a script run out of
# /workspace, so seed.js can `require("firebase-admin")`.
ENV NODE_PATH=/usr/local/lib/node_modules

# Pre-download the emulator JARs at build time. Without this, every `docker compose up`
# on a fresh container re-downloads them, because the cache lives in $HOME and would be
# thrown away with the container.
RUN firebase setup:emulators:firestore \
&& firebase setup:emulators:ui

WORKDIR /workspace

# 4000 Emulator UI · 4400 hub · 4500 logging · 8080 Firestore · 9099 Auth
EXPOSE 4000 4400 4500 8080 9099
121 changes: 121 additions & 0 deletions firebase-emulator/seed.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* Seeds the local Firebase emulators with a handful of deterministic fixtures.
*
* Run by the `seed` service in docker-compose.yml. It reaches the emulators only
* through FIRESTORE_EMULATOR_HOST / FIREBASE_AUTH_EMULATOR_HOST, so firebase-admin
* never looks for a service-account key and this script can never touch production.
*
* Idempotent: document IDs are fixed and writes use set(), and an Auth user that
* already exists is treated as success. Re-running is a no-op in effect.
*/
const admin = require("firebase-admin");

const PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT || "tamushpemobileapp";
const PASSWORD = "password123";

if (!process.env.FIRESTORE_EMULATOR_HOST) {
console.error("Refusing to run: FIRESTORE_EMULATOR_HOST is not set, so this would write to a real project.");
process.exit(1);
}

admin.initializeApp({ projectId: PROJECT_ID });
const db = admin.firestore();
const auth = admin.auth();

/**
* Fixtures mirror the shapes in src/types/user.ts. `gender` lives on privateInfo.
*
* Two users exist on purpose: one already answered the gender question and one has
* never been asked. The second is what GenderPromptModal keys off — it shows only
* when privateInfo.gender is `undefined` — so signing in as that account is the way
* to exercise the prompt locally.
*/
const USERS = [
{
uid: "seed-member-no-gender",
email: "member@tamu.edu",
displayName: "Seed Member",
publicInfo: {
name: "Seed Member",
bio: "Existing account created before the gender step shipped.",
major: "Computer Science",
classYear: "2027",
roles: { reader: true },
points: 12,
pointsThisMonth: 4,
interests: ["Software"],
isStudent: true,
isEmailPublic: false,
},
privateInfo: {
completedAccountSetup: true,
settings: { darkMode: false, useSystemDefault: true },
// No `gender` key at all — this is the account that triggers the prompt.
},
},
{
uid: "seed-officer-with-gender",
email: "officer@tamu.edu",
displayName: "Seed Officer",
publicInfo: {
name: "Seed Officer",
bio: "Account that has already answered the gender question.",
major: "Mechanical Engineering",
classYear: "2026",
roles: { reader: true, officer: true },
points: 140,
pointsThisMonth: 30,
interests: ["Leadership"],
isStudent: true,
isEmailPublic: true,
},
privateInfo: {
completedAccountSetup: true,
settings: { darkMode: true, useSystemDefault: false },
gender: "Prefer not to say",
},
},
];

const seedUser = async (user) => {
try {
await auth.createUser({
uid: user.uid,
email: user.email,
emailVerified: true,
password: PASSWORD,
displayName: user.displayName,
});
} catch (err) {
// A re-run hits an existing account; anything else is a real failure.
if (err.code !== "auth/uid-already-exists" && err.code !== "auth/email-already-exists") {
throw err;
}
}

await db.doc(`users/${user.uid}`).set(
{ uid: user.uid, email: user.email, ...user.publicInfo },
{ merge: true }
);
await db.doc(`users/${user.uid}/private/privateInfo`).set(user.privateInfo, { merge: true });

console.log(` ${user.email} (${user.uid}) — gender: ${user.privateInfo.gender ?? "not set"}`);
};

const main = async () => {
console.log(`Seeding project "${PROJECT_ID}" via ${process.env.FIRESTORE_EMULATOR_HOST}`);

for (const user of USERS) {
await seedUser(user);
}

// Read by fetchLatestVersion() in src/api/firebaseUtils.ts for the update banner.
await db.doc("config/global").set({ latestVersion: "1.1.4" }, { merge: true });

console.log(`Done. Sign in with any seeded email and the password "${PASSWORD}".`);
};

main().then(() => process.exit(0)).catch((err) => {
console.error("Seed failed:", err);
process.exit(1);
});
30 changes: 30 additions & 0 deletions firebase-emulator/start.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/bin/sh
# Entrypoint for the `emulators` service in docker-compose.yml.
#
# This lives in a script rather than inline in the compose file on purpose: a YAML
# folded scalar (`command: >`) does NOT fold lines that are indented deeper than the
# first one, so a multi-line `sh -c "firebase emulators:start ..."` silently splits
# into separate commands and every flag after the first line is lost.
set -e

DATA_DIR=./firebase-emulator/data

# Firebase treats a missing --import directory as a fatal error, so the flag can only
# be passed once an export actually exists. On a clean checkout it must be omitted.
if [ -f "$DATA_DIR/firebase-export-metadata.json" ]; then
echo "==> importing saved emulator state from $DATA_DIR"
set -- --import="$DATA_DIR"
else
echo "==> no saved state found; starting with an empty database"
set --
fi

# --only keeps this minimal and, importantly, stops Firebase from starting the
# functions/pubsub/storage emulators declared in firebase.json. The functions source
# directory is not mounted into this container at all. The Emulator UI starts
# regardless of --only.
exec firebase emulators:start \
--project tamushpemobileapp \
--only firestore,auth \
--export-on-exit="$DATA_DIR" \
"$@"
9 changes: 9 additions & 0 deletions firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,17 @@
},
"ui": {
"host": "0.0.0.0",
"port": 4000,
"enabled": true
},
"hub": {
"host": "0.0.0.0",
"port": 4400
},
"logging": {
"host": "0.0.0.0",
"port": 4500
},
"singleProjectMode": true
},
"storage": {
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
"go": "npx expo start --go",
"go-tunnel": "npx expo start --tunnel --go",
"go-localhost": "npx expo start --localhost --go",
"dev-client-sim": "eas build --profile development-sim"
"dev-client-sim": "eas build --profile development-sim",
"emulators": "docker compose up",
"emulators:reset": "docker compose down -v && rm -rf firebase-emulator/data"
},
"dependencies": {
"@expo/config-plugins": "~10.1.1",
Expand Down
Loading
Loading