# @crvouga/mockingbird-service-fcm

> Stateful mock of the Firebase Cloud Messaging HTTP v1 send API used by firebase-admin: fixture projects and registration tokens, an outbox, and a device inbox.

Latest version **0.2.0** (published 2026-10-02) · MIT license · 0 weekly downloads

## Install

```sh
npm install @crvouga/mockingbird-service-fcm
pnpm add @crvouga/mockingbird-service-fcm
yarn add @crvouga/mockingbird-service-fcm
bun add @crvouga/mockingbird-service-fcm
```

Provides the command `mockingbird-fcm`.

## Health

**Score 75/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; has provenance; recently updated; high maintenance score; high quality score.

Warnings: low downloads; pre 1.0.

## Facts

| | |
|---|---|
| Version | 0.2.0 |
| Published | 2026-10-02 |
| First published | 2026-10-01 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | bundled |
| Module format | ESM + CommonJS |
| Dependencies | 2 |
| Unpacked size | 883.7 KB |
| Known vulnerabilities | 0 (+35 in 1 direct dependencies) |
| Install scripts | no |
| Provenance | attested (GitHub Actions) |
| GitHub stars | 3 |
| Maintainers | crvouga |
| Keywords | mockingbird, service, fcm, firebase, firebase-admin, messaging |

## Links

- npm: https://www.npmjs.com/package/@crvouga/mockingbird-service-fcm
- Repository: https://github.com/crvouga/mockingbird
- Homepage: https://github.com/crvouga/mockingbird/tree/main/packages/service/fcm#readme
- Issues: https://github.com/crvouga/mockingbird/issues
- npm.io page: https://npm.io/package/@crvouga/mockingbird-service-fcm

## Dependencies (2)

- [hono](https://npm.io/package/hono.md) 4.11.9
- [@crvouga/mockingbird-service-sqlite](https://npm.io/package/@crvouga/mockingbird-service-sqlite.md) 1.3.0

## Alternatives

- [pagerjs](https://npm.io/package/pagerjs.md) — 60 weekly downloads
- [whistle.savefor-mock](https://npm.io/package/whistle.savefor-mock.md) — 4 weekly downloads
- [jabber](https://npm.io/package/jabber.md) — 0 weekly downloads
- [@simulacrum/ldap-simulator](https://npm.io/package/@simulacrum/ldap-simulator.md) — 0 weekly downloads
- [@crvouga/mockingbird-service-paddle](https://npm.io/package/@crvouga/mockingbird-service-paddle.md) — 0 weekly downloads

## Recent versions

- 0.2.0 (latest) — 2026-10-02
- 0.1.0 — 2026-10-01
- 0.0.0-stage — 2026-10-01

## README

# @crvouga/mockingbird-service-fcm

> Familiar calls. Faithful echoes. Part of [Mockingbird](https://github.com/crvouga/mockingbird).

Stateful mock of the **Firebase Cloud Messaging HTTP v1** send API (`POST /v1/projects/{project_id}/messages:send`), the call `firebase-admin` makes. Projects, access tokens, and registration tokens are fixtures. There is no FCM sandbox. This package is `status: "wip"`.

- Operation coverage: [SUPPORT.md](https://github.com/crvouga/mockingbird/blob/main/packages/service/fcm/SUPPORT.md)
- The contract (`openapi.yaml`) is hand-authored from the FCM REST reference and firebase-admin 12.7.0 / 13.5.0.

## Install

```bash
npm install -D @crvouga/mockingbird-service-fcm
```

ESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with `npx mockingbird-fcm serve`, `createServer` from `./server` (Node), or `createRuntime` with any Fetch server.

## Usage

`firebase-admin` hardcodes `https://fcm.googleapis.com`. Point it at the mock with `createAdminTransport` (an `https.Agent` that dials the mock) and call `enableLegacyHttpTransport()` so multicast sends honor that agent. `send` is already HTTP/1.1. The fixture project is `demo-project`, the fixture device token is `fixture-device-token`, and any bearer is accepted until you turn on `strict`.

```ts
import { createServer } from "@crvouga/mockingbird-service-fcm/server"
import { createAdminTransport } from "@crvouga/mockingbird-service-fcm/admin"

const fcm = await createServer()
const transport = await createAdminTransport({ origin: fcm.url, token: "fixture-token" })
// initializeApp({ projectId: "demo-project", credential: transport.credential, httpAgent: transport.agent })
// getMessaging(app).enableLegacyHttpTransport()
await transport.close()
await fcm.close()
```

Without the SDK, `POST` the wire body and read the outbox:

```ts
import { createRuntime, FCM_FIXTURE_PROJECT, FCM_FIXTURE_TOKEN } from "@crvouga/mockingbird-service-fcm"

const fcm = createRuntime()
const response = await fcm.fetch(
  new Request(`http://fcm.test/v1/projects/${FCM_FIXTURE_PROJECT}/messages:send`, {
    method: "POST",
    headers: { authorization: "Bearer fixture-token", "content-type": "application/json" },
    body: JSON.stringify({
      message: { token: FCM_FIXTURE_TOKEN, notification: { title: "Hello", body: "World" } },
    }),
  }),
)
const body = (await response.json()) as { name: string }
const outbox = await (await fcm.fetch(new Request("http://fcm.test/__admin/outbox"))).json()
void body
void outbox
```

### Routes

| Route | Behaviour |
| --- | --- |
| `POST /v1/projects/{project_id}/messages:send` | Bearer required. Body `{ message, validate_only? }`. One registration `token` (topic and condition are rejected). Success is `{ name: "projects/{project_id}/messages/{id}" }`. Errors are a Google RPC envelope. `validate_only: true` validates and returns a name without storing anything. |
| `GET /health` | Liveness, plus the `x-mockingbird` header on every response. |

### Admin

| Route | Behaviour |
| --- | --- |
| `GET /__admin/outbox` | Accepted messages, oldest first. Filters: `token`, `platform`, `project`, `since` (epoch ms), `collapseKey`, `state`. Each row has the normalized fields and the original `wire` message. |
| `POST /__admin/tokens` | Register `{ token, platform?, project?, state?, appId? }`. `tokens: [...]` registers a batch. |
| `POST /__admin/tokens/:token/expire` | Mark expired. Optional `{ replacement }`. |
| `POST /__admin/tokens/:token/state` | `{ state: active \| expired \| unregistered \| sender_mismatch }`. |
| `GET /__admin/inbox/:token` | Device inbox, in delivery order. |
| `POST /__admin/inbox/:token` | `{ action: "ack" }` drops the oldest item. `{ action: "clear" }` empties it. |
| `POST /__admin/deliver` | Deliver accepted messages. Expired ones stay in the outbox as `expired`. |
| `POST /__admin/messages/:id/deliver` | Deliver one accepted message. |
| `POST /__admin/messages/:id/drop` | Mark `dropped` and do not inbox it. |
| `POST /__admin/messages/:id/duplicate` | Copy the inbox entry again. |
| `GET /__admin/settings`, `PUT /__admin/settings` | `strict`, `automaticDelivery` (default true), `collapse` (default false), `credentials` (bearer → `{ project, expired? }`), `clockOffsetMs`. |
| `POST /__admin/time` | `{ advanceMs }` moves this namespace's offset only. |
| `POST /__admin/scripts` | `{ token?, errorCode, count, httpStatus? }`. The next `count` sends to that token (or every token when `token` is omitted) return that Google RPC error and store nothing. Then the token's own state applies. |
| `GET /__admin/scripts`, `DELETE /__admin/scripts` | List or clear scripts. `?token=` clears one. |

Logical message time is `clockOffsetMs` plus the process-wide runtime clock. `POST /__admin/clock` still moves every namespace. Records, faults, outboxes, inboxes, and message ids are per namespace.

### Presets

`POST /__admin/faults {"preset": "<name>"}`. Each one targets `SendMessage`:

| Preset | Effect |
| --- | --- |
| `invalid_auth` | 401 `UNAUTHENTICATED`, nothing stored |
| `permission_denied` | 403 `PERMISSION_DENIED`, nothing stored |
| `invalid_argument` | 400 `INVALID_ARGUMENT`, nothing stored |
| `unregistered` | 404 `NOT_FOUND` + FcmError `UNREGISTERED`, nothing stored |
| `sender_mismatch` | 403 + FcmError `SENDER_ID_MISMATCH`, nothing stored |
| `quota_exceeded` | 429 `RESOURCE_EXHAUSTED`, `Retry-After`, `google.rpc.RetryInfo`, FcmError `QUOTA_EXCEEDED` |
| `unavailable` | 503 + FcmError `UNAVAILABLE` |
| `internal` | 500 + FcmError `INTERNAL` |
| `slow` | waits 15s (the Firebase Admin send timeout) |
| `drop` | connection drop before accept; outbox stays empty |
| `accepted_then_network_drop` | outbox record is stored, then the connection drops |

### Namespaces

`x-mockingbird-namespace`, a `/ns/<name>/` prefix, or `PUT /__admin/credentials` (bearer → namespace). A bearer listed in `settings.credentials` authorizes only that project. `strict: true` rejects bearers that are not listed. Webhooks: none. The device inbox is a test control, not a callback.

### SDK error codes

firebase-admin maps `error.details[].errorCode` when `@type` is `type.googleapis.com/google.firebase.fcm.v1.FcmError`, otherwise `error.status`. Two mappings differ from a naive reading of the error-code names, and the mock follows firebase-admin 12.7.0 and 13.5.0:

- A JSON 401 with status `UNAUTHENTICATED` and no FcmError details becomes `messaging/third-party-auth-error`. `messaging/authentication-error` is what the SDK uses for a non-JSON 401.
- FcmError `DEADLINE_EXCEEDED` is not in the messaging map, so the SDK reports `messaging/unknown-error`. `UNAVAILABLE` is `messaging/server-unavailable`.

`INVALID_ARGUMENT`, `SENDER_ID_MISMATCH`, `QUOTA_EXCEEDED`, `UNAVAILABLE`, and `INTERNAL` map to `messaging/invalid-argument`, `messaging/mismatched-credential`, `messaging/message-rate-exceeded`, `messaging/server-unavailable`, and `messaging/internal-error`. Status `PERMISSION_DENIED` (a bearer for another project) is also `messaging/mismatched-credential`.

### Deliberately not modelled

- Firebase Auth, Firestore, Realtime Database, Hosting, Remote Config, Analytics, and client installation APIs.
- Topic and condition sends, and topic subscription management.
- APNs and Android transport, real devices, app callbacks, notification rendering, badges, and sounds.
- Production quotas and Google's unpublished retry schedule. The Admin SDK's own retry (503, `ECONNRESET`, `ETIMEDOUT`) is left to the SDK.

## API

| Export | Kind | Description |
| --- | --- | --- |
| `FcmAPI` | class | In-process mock: `fetch(request)`, `reset()`, `logicalNow()`, `outbox(query)`, `inbox(token)`, `registerToken`, `setTokenState`, `ackInbox`, `deliverPending`, `dropMessage`, `duplicateMessage`, `putScript`. Options: `sqlite`, `now`, `namespace`, `settings`. |
| `createRuntime` | function | Mock plus `/health`, `/__admin/*`, namespaces, clock, presets, and the journal. Options: `sqlite`, `clock`, `seed`, `adminKey`, `onLog`, `settings`. |
| `FCM_PRESETS` | object | Named fault presets. |
| `FCM_NAMESPACE` | string | Service name, `"fcm"`. |
| `FCM_FIXTURE_PROJECT` | string | Seeded project id, `"demo-project"`. |
| `FCM_FIXTURE_TOKEN` | string | Seeded Android registration token, `"fixture-device-token"`. |
| `FCM_FIXTURE_BEARER` | string | Bearer the playground sends, `"fixture-token"`. Accepted whenever `strict` is off. |
| `fcmCredential` | function | `(accessToken?)` → `{ getAccessToken }` for `initializeApp({ credential })`. |
| `document`, `operationIds`, `supportedOperationIds` | values | The OpenAPI contract and its operation ids. |
| `createServer`, `serveTarget`, `DEFAULT_PORT` (`./server`) | Node | Serve over `node:http`. CLI flag `--strict`. Port 8826. |
| `createAdminTransport` (`./admin`) | Node | `{ origin, token? }` → `{ credential, agent, port, close }`. `agent` is an `https.Agent` aimed at the mock. |

Part of [mockingbird](https://github.com/crvouga/mockingbird).

---
_Source: https://npm.io/package/@crvouga/mockingbird-service-fcm · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
