# Firebase / Crashlytics Onboarding (setup Step 3c)

Loaded on demand by `/multi-agent:setup` Step 3c (optional, any platform). The SKILL.md carries the step intro; this file is the full flow.

Runs inside Step 3 alongside the other missing credentials. A user who already
holds a Firebase service-account JSON gets it mapped by Step 1 discovery like any
other credential; this flow covers what discovery cannot see - whether that
account may actually read Crashlytics, and what to do when it may not.

## 1. Why there are three ways in

Crashlytics has one API and three ways to authenticate against it. They fail
differently, so the pipeline measures rather than assumes.

| Tier | Path | Serves | Needs |
|---|---|---|---|
| 1 | Service account + `lib/fetch-crashlytics.sh` | headless: autopilot, cron, url-enrichment, review chains | a Crashlytics role on the service account |
| 2 | Firebase MCP + an interactive `firebase login` | a person at the keyboard | a browser, a real terminal, a session that expires |
| 3 | The user pastes the stack trace | last resort | nothing, and it produces degraded evidence |

Order is not fixed - whichever tier is ready wins, exactly as in the Figma chain.
But **tier 1 is preferred whenever both are ready**, and the reason is structural:
url-enrichment expands a Crashlytics link found in a Jira ticket while nobody is
watching, and autopilot runs with no terminal to log into. A tier that needs a
browser cannot serve those. Tier 2 fills the gap while a role request is pending;
it does not replace tier 1.

`credential-inventory.sh --probe` reports which tier is live:
`tier-1-ready` / `tier-2-ready` / `tier-1-no-grant` / `malformed` / `unreachable`.
The vocabulary and what to tell the user for each: `refs/keychain.md`.

## 2. Tier 1 - the service account

One credential, `keychainMapping.firebase`, holding the JSON exactly as Firebase
Console issued it. Firebase Console -> Project settings -> Service accounts ->
Generate new private key. Store it through the normal Token Save Flow; it is
multi-line and that is fine - the store round-trips it byte for byte.

Do **not** base64 it. Encoding is the store's internal business, and a wrapper on
the way in that nothing unwraps on the way out is how this path stayed broken.

Several Firebase projects per team is the normal case (legacy plus redesign,
staging plus prod). The single slot is the fallback; extra projects go in
`prefs.global.firebase.accounts[]` as `{projectId, keychainKey, label}` and the
URL's project id picks the key.

**The role is the part discovery cannot see.** A freshly generated service account
authenticates perfectly and reads nothing: `firebase-adminsdk-*` carries no
Crashlytics permission by default. The probe surfaces this as `tier-1-no-grant`,
and the fix is one line to whoever holds Owner on the project:

> Please grant `roles/firebasecrashlytics.viewer` on project `<projectId>` to the
> service account `<client_email>`. It is read-only: it allows reading crash
> issues and their stack traces, and nothing else.

`roles/firebase.viewer` also works and is broader. Ask for the narrower one first.

Verify with `bash lib/fetch-crashlytics.sh --probe`. It asks IAM what the account
may do rather than calling Crashlytics and reading the error, because a 403 from
the data endpoint means "no permission" but so does a 403 from a disabled API,
and those need different fixes.

## 3. Tier 2 - interactive session plus MCP

Two halves, both required. Either alone reaches nothing.

**The session.** `firebase login` opens a browser and does not work from inside an
agent harness - it needs a real terminal the user drives themselves. Ask them to
run it and say when it is done; `firebase login:list` confirms.

**The MCP server.** The Firebase CLI serves it itself - there is no package to
install beyond the CLI the login already needed:

```bash
claude mcp add firebase -- firebase mcp --dir "$PWD"
```

`--dir` is not decoration: the server resolves the project from that directory's
`.firebaserc` / `firebase.json`, so a server registered without it answers for
whatever directory it happens to start in.

Scope is a real choice with three answers, so ask rather than guess:

| Scope | Where it lands | When |
|---|---|---|
| `local` (default) | this user's entry for this repo, in `~/.claude.json` | the normal answer - the grant stays scoped to the repo that needs it |
| `user` | this user, every repo | only when the user works across several Firebase projects and says so; `--dir` then has to be re-pointed per repo |
| `project` | `.mcp.json` **committed in the repo** | only on an explicit ask - this registers the server for everyone who clones it |

Default to `local` and never reach for `project` on your own: the server can read
that Firebase project's data, and committing it hands that reach to the whole
team as a side effect of one person's setup.

Verify the registration answers before calling tier 2 ready:

```bash
claude mcp list | grep -i firebase
```

A tier-2 session is a person's credential with a refresh token that expires. Never
present it as the durable answer - it is the bridge while the role request moves.

## 4. appId discovery

Every Crashlytics call needs the opaque `appId` (`1:<number>:ios:<hex>`), and a
console URL only ever carries the bundle id. The fetcher resolves it through the
Firebase Management API on each run, which works but costs a round trip and needs
the project resolved first.

The repo already holds the answer. `GoogleService-Info*.plist` (iOS) and
`google-services.json` (Android) carry `PROJECT_ID` and `GOOGLE_APP_ID` for every
target:

```bash
bash "$HOME/.claude/scripts/firebase-app-discovery.sh" <repo-dir> --json
```

It prints entries shaped for `prefs.global.firebase.accounts[]`, each with an
`apps[]` of `{bundleId, appId, platform}`. Merge them into the account that
already carries the matching `projectId`, keeping its `keychainKey` - which
credential covers a project is the user's mapping to make, so the script never
guesses one.

A repo with several targets has several plists and they do not all point at the
same Firebase project, so the script reads every match rather than stopping at
the first, and skips build outputs where a copied plist would count one app twice.

Confirm the merge before writing: show the projectId and the app count, and write
nothing on a decline. Finding nothing is a normal answer - the fetcher still
resolves appIds live, one round trip per run.

## 5. v1alpha, and what to do when it breaks

Both tiers read `firebasecrashlytics.googleapis.com/v1alpha`. That surface is
undocumented and unversioned in the usual sense: Google may change or withdraw it
without notice, and when they do, both tiers fail at once.

This is written down so the failure is diagnosable rather than mysterious. The
symptom is a 404 or a changed response shape on a call that worked yesterday, with
credentials that still pass `--probe`. When it happens, the fetcher exits 3 with
`crashlytics-unreachable`, the orchestrator degrades to advisory, and the pipeline
keeps moving - it does not halt a run over a crash report.

## 6. Skipping

All of it is optional. Skip and nothing is written; Crashlytics links in tickets
are simply not enriched, and the run says so rather than pretending it looked.

Never lead with "paste the stack trace yourself" - that is tier 3, and offering
the last resort first trains the user to skip the durable fix (`refs/keychain.md`
Rule 2).
