# Ephemera — Static web on Firebase Hosting (GCP + firebase CLI) — SPA or multipage

> Self-executing Markdown. Same **intent** as [`web.aws.md`](./web.aws.md) / [`web.cloudflare.md`](./web.cloudflare.md) /
> [`web.gcp.md`](./web.gcp.md); different **binding**. The cloud is the source of truth; this file is intent + write-back
> ledger + audit trail.
>
> **Provides** `site(${DOMAIN_NAME:-${SITE_ID}.web.app})` — a live, CDN-fronted, HTTPS static site.
> **Requires** `delegated-zone(${DOMAIN_NAME})` **only when `DOMAIN_MODE != none`** (any registrar binding —
> [`domain.aws.md`](./domain.aws.md) or [`domain.cloudflare.md`](./domain.cloudflare.md)); the `none` branch needs
> **nothing** — Firebase mints a free `*.web.app` + `*.firebaseapp.com` HTTPS hostname automatically.

## 🤖 Director prompt

Same contract as the sibling web plans: observe before acting; verify each step; stop at 🔴/💥 for human go; write
realized values back into Live State. Firebase deploys are seconds and reverse with one command, so the only ⏳ wait is
the custom-domain cert (minutes–hours, Firebase-managed) and the only hard gate is the outward-facing deploy + the
console-driven custom-domain step.

> **Status: DOGFOODED LIVE 2026-06-30 (`none`-branch, both `SITE_TYPE`s).** Ran end-to-end against a throwaway project
> (`ephemera-fb-demo-0630`, created + deleted, ~$0 — Hosting is free-tier, no LB): **static** and **spa** both PASS the
> full contract on the free `*.web.app` — root `200`+marker; static miss→real `404` + custom `404.html`; spa deep
> route→`200` app-shell; valid **Google Trust Services** cert; `http→https` 301. The §1 discover-or-create
> observe-before-act, `firebase deploy -m`, and the `hosting:sites:delete` teardown all confirmed live. **The headline
> holds: `none` yields a real HTTPS URL — closing Asymmetry #3 that [`web.gcp.md`](./web.gcp.md)'s bare IP could not.**
> **Still unrun:** the `DOMAIN_MODE != none` custom-domain path (§4) is **console-driven** (no clean CLI verb) — modeled
> as a `manual` 🔴 branch; first run proves it. **The first-dogfood surprise (the thesis again):** a never-used-Firebase
> account cannot enable Firebase from the CLI at all (`addFirebase` 403s even as owner) — a one-time **console**
> activation is required first; see the prereq in **What you need** + §1.

```
Legend  🟢 create · 🟡 config · 🔴 GATE (human go) · 💥 destructive (human go) · ⏳ wait · ✔ verify
```

## What you need, and why  *(read if Firebase is new to you)*

- **`firebase-tools` CLI** (`npm i -g firebase-tools`) authenticated as a principal with Firebase access to the project.
  *Why:* every step is a `firebase …` call. *Have it already?* `firebase --version` prints, `firebase projects:list`
  shows your projects.
- **A Firebase-enabled GCP project** (`GCP_PROJECT`). *Why:* a Firebase project **is** a GCP project with Firebase
  enabled; Hosting sites live inside it. *Have it already?* `firebase projects:list` lists it; if it's a plain GCP
  project, §1 enables Firebase on it (`projects:addfirebase`). **This plan does not create or delete the project** —
  the project is a *borrowed* platform concern (same seam as [`web.gcp.md`](./web.gcp.md)).
- 🔴 **One-time, per-account: Firebase must be activated for your Google account before *any* CLI enablement works.**
  *Dogfood finding (2026-06-30):* on an account that has **never used Firebase**, `firebase projects:addfirebase`
  returns **403 PERMISSION_DENIED even as project owner with the APIs enabled** — Google gates first-time activation
  (Firebase ToS) behind the **console**. Fix it **once**: at <https://console.firebase.google.com> → *Add project* →
  *Add Firebase to an existing Google Cloud project* (pick `GCP_PROJECT`) → **accept the terms**. After that, the CLI
  works for this and all future projects, and §1's `addfirebase` reuse-branch takes over. *Have it already?* if
  `firebase projects:list` shows **any** project, your account is activated.
- **(custom domain only) a delegated zone** you control DNS for — see **Requires** above. *Why:* a custom domain needs
  an ownership `TXT` and Firebase `A` records. *Have it already?* `dig NS ${DOMAIN_NAME}` shows the authoritative zone.

## Intent

Serve a **static website** over HTTPS from Google's global CDN, with the origin never publicly browsable as a bucket.
**`SITE_TYPE` decides the routing:** `spa` returns the app shell for unknown paths (client-side routing); `static`
serves real files and returns a real `404` for misses. **Identical intent to the AWS / Cloudflare / GCP-LB plans** —
only the binding differs. This is GCP's **bundled, managed** static host (the Cloudflare-Pages-equivalent): no load
balancer, no url-map, no cert juggling — Firebase owns the CDN, the TLS, and the free hostname.

**Shared acceptance contract** (the same test all bindings must pass — mirrors [`web.aws.md`](./web.aws.md) /
[`web.cloudflare.md`](./web.cloudflare.md) / [`web.gcp.md`](./web.gcp.md)):
1. `GET /` → `200` + body marker `${CONTENT_MARKER}` (default `EPHEMERA-OK`)
2. deep link — `spa`: `GET /some/deep/route` → `200` (app-shell fallback); `static`: a real path → `200`, a miss → `404`
3. HTTPS enforced

> **Note on "private origin":** the AWS plan asserts "direct S3 URL → 403" and the GCP-LB plan honestly admits its
> backend bucket is public-read. On Firebase there is *no separate origin to lock down* — assets live on Firebase's
> managed edge, served only through the CDN. The private-origin requirement is satisfied by *architecture* (same as
> Cloudflare), so there is nothing to assert. That absence is the portability insight, not a gap.
>
> **Note on "free preview URL" — this binding closes the gap [`web.gcp.md`](./web.gcp.md) could not.** The GCP-LB plan
> records **Asymmetry #3**: its `DOMAIN_MODE=none` mints only a bare anycast IP (HTTP-only — a managed cert can't bind a
> bare IP), so `none` there is smoke-test-only. **Firebase's `none` is a *real, usable HTTPS URL*** (`*.web.app`, auto
> cert) — exactly the `*.cloudfront.net` / `*.workers.dev` parity AWS and Cloudflare have. That is the headline reason
> this fourth binding exists.

## Provisioning Inputs

> Same first five rows as the sibling web plans — the **inputs are identical across bindings**; only realization
> differs. The Director resolves them once, writes `resolved_inputs` into Live State, and branches the steps below.
> Firebase adds two binding-locals: `GCP_PROJECT` and `SITE_ID`. `DOMAIN_MODE=none` yields the free `*.web.app` host.

| # | Question | Options (closed enum) | Default | Sets | Gates (Firebase binding) |
|---|----------|-----------------------|---------|------|--------------------------|
| 1 | Site type | `spa` / `static` | `spa` | `SITE_TYPE` | §2 `firebase.json` `rewrites` (catch-all `**`→`/index.html` vs none + `404.html`) and §5 acceptance |
| 2 | Custom domain? | `none` / `apex` / `subdomain` | `none` | `DOMAIN_MODE` | whether §4 (custom-domain connect) runs at all |
| 3 | Domain name | text — `example.com` / `app.example.com` | — | `DOMAIN_NAME` | required iff `DOMAIN_MODE≠none`; §4, §5 |
| 4 | Manage DNS where? | `managed` / `external` / `manual` | `manual` | `DNS_MODE` | §4 — Firebase domain-connect is **console-driven**, so `manual` (print records → human applies → verify) is the honest default; `managed` adds the records via a delegated-zone binding |
| 5 | Content marker | text | `EPHEMERA-OK` | `CONTENT_MARKER` | §5 acceptance assertion |
| 6 | GCP project (Firebase-enabled) | text — project id | — | `GCP_PROJECT` | all steps (borrowed; never created/deleted here) |
| 7 | Hosting site id | text — hostname label, ≤30 chars, **globally unique** | `${GCP_PROJECT}-web` (normalized, truncated) | `SITE_ID` | §1, §3, §5, teardown; **is** the `*.web.app` subdomain |

> **Determinism — non-idempotent create calls.** `firebase hosting:sites:create` and `firebase projects:addfirebase`
> both **error if the thing already exists** (not idempotent). The plan restores "same answers ⇒ same resource" by
> **observing before acting**: it lists first and reuses on a hit, creates only on a miss (§1). `SITE_ID` is a **pure
> function of the inputs** (default derived from `GCP_PROJECT`), so a re-run targets the same site — but it must be
> **globally unique across all of Firebase**, so a collision is a 🔴 human override, not silent drift. **Teardown
> caveat (proven live):** `hosting:sites:delete` **permanently retires** the `SITE_ID` ("cannot be reactivated by you
> or anyone else") — so a teardown-then-re-stand-up with the *same* deterministic `SITE_ID` will **fail** at
> `sites:create`. Re-standing-up after a full teardown needs a **fresh** `SITE_ID`.

## Live State

```yaml
status:        not-created      # published template - run it to realize state
last_action:   teardown — project ephemera-fb-demo-0630 deleted after the 2026-06-30 dogfood
last_verified: 2026-06-30 dogfood (none-branch, throwaway project, torn down): static + spa both PASS — root 200+marker, static miss→404+custom 404.html, spa deep→200 app-shell, Google Trust Services cert, http→https 301. Custom-domain (§4) still unrun (console-driven).

resolved_inputs:
  site_type:   spa           # spa | static (multipage)
  domain_mode: none          # none | apex | subdomain
  domain_name: —             # required iff domain_mode != none
  dns_mode:    manual        # managed | external | manual (Firebase connect is console-driven)
realized:
  GCP_PROJECT:  —            # borrowed (not created here)
  SITE_ID:      —            # the *.web.app subdomain
  SITE_URL:     —            # https://${SITE_ID}.web.app (free, auto-cert)
  RELEASE:      —            # firebase hosting release id from the last deploy
  CERT_STATUS:  —            # (custom domain) PENDING | ACTIVE — Firebase-managed
```

| ✔ check                    | expected                          | observed | result |
|----------------------------|-----------------------------------|----------|--------|
| root → 200 + marker        | 200 + `${CONTENT_MARKER}`         | —        | —      |
| deep link (per SITE_TYPE)  | spa:200 shell / static:404 miss   | —        | —      |
| HTTPS enforced             | TLS on `*.web.app` (or domain)    | —        | —      |
| origin not separately browsable | n/a — managed edge, **negative is structural** | — | —  |

## Tags & provenance (binding asymmetry)

**Firebase Hosting has no per-resource tag/label API** — a Hosting *site* takes no labels (same shape as Cloudflare
Workers). The underlying **GCP project** carries labels, but the project is **borrowed** here, and Ephemera's rule is
*only tag what this plan creates* — so this plan adds **no** labels. Provenance is carried **structurally**:

- **Naming convention** — `SITE_ID` is deterministic (`${GCP_PROJECT}-web`); the `*.web.app` subdomain *is* the
  "what manages this" signal, and it's discoverable via `firebase hosting:sites:list`.
- **Release message** — `firebase deploy -m "Source=… PlanVersion=…"` records provenance on the Hosting release
  (the closest analog to `wrangler deploy --message`). *(Confirmed live 2026-06-30 — `-m` is a valid `deploy` flag.)*

This gap is the **portability insight**, not a defect — the same shape as the no-resource-tags asymmetry
[`web.cloudflare.md`](./web.cloudflare.md) records. On Firebase, "who manages this" is architecture (plan + naming),
not a per-resource label. (Contrast [`web.gcp.md`](./web.gcp.md), where the GCS bucket *does* take labels — so the two
GCP bindings differ even on provenance.)

## 0. Variables

```bash
set -euo pipefail
export GCP_PROJECT="${GCP_PROJECT:?set the Firebase-enabled GCP project id}"
export SITE_TYPE="${SITE_TYPE:-spa}"            # spa | static (multipage)
export DOMAIN_MODE="${DOMAIN_MODE:-none}"       # none | apex | subdomain
export DOMAIN_NAME="${DOMAIN_NAME:-}"           # required iff DOMAIN_MODE != none
export DNS_MODE="${DNS_MODE:-manual}"           # managed | external | manual (console-driven connect)
export CONTENT_MARKER="${CONTENT_MARKER:-EPHEMERA-OK}"
export ASSETS_DIR="${ASSETS_DIR:-./site}"       # directory of files to serve
export ENV="${ENV:-prod}"

# SITE_ID — deterministic, hostname-safe, ≤30 chars (the *.web.app subdomain). Globally unique across Firebase:
# a collision is a 🔴 human override (pass SITE_ID=… explicitly), never silent drift.
export SITE_ID="${SITE_ID:-$(printf '%s' "${GCP_PROJECT}-web" \
  | tr 'A-Z' 'a-z' | sed 's/[^a-z0-9-]/-/g; s/^-*//; s/-*$//' | cut -c1-30 | sed 's/-*$//')}"
export TARGET="${TARGET:-web}"                  # deploy-target alias mapped to SITE_ID in §2

# Provenance (Firebase Hosting has no resource tags — see "Tags & provenance"):
export TAG_SOURCE="web.gcp-firebase.md"         # "what created this"
export TAG_PLAN_VERSION="2026-06-30"            # bump on meaningful edits

firebase --version >/dev/null || { echo "install firebase-tools: npm i -g firebase-tools" >&2; exit 1; }
```

## Dependency frontier

```
ensure-firebase(project) ─> site(SITE_ID) ─> firebase.json (SITE_TYPE) ─> deploy ─┬─ none      ─> verify ─> Provides site(SITE_ID.web.app)
                                                                                   └─ apex/sub  ─> Requires delegated-zone ─> §4 connect (🔴 console) ─> ⏳ cert ─> verify ─> Provides site(DOMAIN_NAME)
SITE_TYPE? ─── spa ─> rewrites ** → /index.html (200 shell)   ·   static ─> no catch-all + 404.html (real 404)
```

Essentially no ordering to thread — **that collapse is the whole point of the binding comparison** vs
[`web.gcp.md`](./web.gcp.md)'s bucket→backend-bucket→url-map→proxy→forwarding-rule→cert chain. One config, one deploy.

## 1. Firebase on the project + the Hosting site  🟢  *(discover-or-create — both calls are non-idempotent)*

```bash
# 🔴 FIRST-TIME-ONLY (per account): if `firebase projects:list` is empty, your account has never used Firebase —
#     the CLI `addfirebase` below WILL 403 (proven live). Do the one-time console activation first (see "What you
#     need"), then re-run. This is account-level, not per-project; skip it forever after.

# 🟡 a fresh gcloud-created project needs the Firebase Management + Hosting APIs before addfirebase (proven live:
#     a bare project 403'd until these were enabled; the console add-project flow enables them for you).
gcloud services enable firebase.googleapis.com firebasehosting.googleapis.com --project "$GCP_PROJECT"

# 🟢 ensure the GCP project has Firebase enabled (addfirebase ERRORS if already enabled → observe first)
if firebase projects:list 2>/dev/null | grep -qw "$GCP_PROJECT"; then
  echo "project '$GCP_PROJECT' already Firebase-enabled — reuse"
else
  firebase projects:addfirebase "$GCP_PROJECT"
fi

# 🟢 ensure the Hosting site exists (sites:create ERRORS if it exists → observe first; SITE_ID is deterministic).
# NOTE the `grep -qw` edge case (proven live): grep treats hyphens as word boundaries, so `grep -w` on a SHORTER
# id would also match a line carrying a longer `${id}-suffix` site. The plan's default SITE_ID is the *longest*
# (`${GCP_PROJECT}-web`), so it's safe against the auto-created default site `${GCP_PROJECT}` — but if you pick a
# SITE_ID that is a hyphen-prefix of another existing site, switch this observe to a `--json` parse.
if firebase hosting:sites:list --project "$GCP_PROJECT" 2>/dev/null | grep -qw "$SITE_ID"; then
  echo "site '$SITE_ID' already exists — reuse"
else
  firebase hosting:sites:create "$SITE_ID" --project "$GCP_PROJECT"
fi
```
```bash
# ✔ the site is present and reports its free hostname  (output is a bordered table with a "Site ID" column — proven live)
firebase hosting:sites:list --project "$GCP_PROJECT" 2>&1 | grep -w "$SITE_ID" \
  && echo "site live at: https://${SITE_ID}.web.app"
```
> → Live State: `SITE_ID`, `SITE_URL=https://${SITE_ID}.web.app`; `status: creating`.
> *(Adding Firebase via the console auto-creates a **default** site `${GCP_PROJECT}.web.app` too; this plan creates a
> **named** site so the `*.web.app` host is a pure function of `SITE_ID`. You may instead deploy to the default site
> and skip `sites:create` — a simpler one-site variant.)*

## 2. Config + content — generated from resolved inputs  🟡

The `firebase.json` *is* the infrastructure, and it's a **pure function of the resolved inputs** (`ASSETS_DIR`,
`SITE_TYPE` → the catch-all rewrite, `TARGET` → `SITE_ID`).

```bash
# SITE_TYPE branch: spa adds the app-shell catch-all; static omits it (a miss serves 404.html with a real 404).
REWRITES=""
[ "$SITE_TYPE" = spa ] && REWRITES='"rewrites": [ { "source": "**", "destination": "/index.html" } ],'

cat > firebase.json <<JSON
{
  "hosting": {
    "target": "${TARGET}",
    "public": "${ASSETS_DIR}",
    "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
    ${REWRITES}
    "cleanUrls": true,
    "trailingSlash": false
  }
}
JSON

# map the deploy target alias → the site created in §1 (so `deploy --only hosting:${TARGET}` is unambiguous)
firebase target:apply hosting "$TARGET" "$SITE_ID" --project "$GCP_PROJECT"

# ensure the content carries the marker (§5 asserts it); static also needs a real 404.html.
[ -f "${ASSETS_DIR}/index.html" ] || { echo "ASSETS_DIR has no index.html" >&2; exit 1; }
grep -q "$CONTENT_MARKER" "${ASSETS_DIR}/index.html" || echo "⚠ marker '${CONTENT_MARKER}' absent from index.html"
[ "$SITE_TYPE" = static ] && [ ! -f "${ASSETS_DIR}/404.html" ] && echo "⚠ static: add a ${ASSETS_DIR}/404.html for the real-404 path"
```
```bash
# ✔ generated config is valid JSON and branches correctly on SITE_TYPE
python3 -c "import json,sys; json.load(open('firebase.json')); print('firebase.json valid')"
[ "$SITE_TYPE" = spa ] && grep -q '"source": "\*\*"' firebase.json && echo "spa app-shell rewrite present"
[ "$SITE_TYPE" = static ] && ! grep -q '"rewrites"' firebase.json && echo "static: no catch-all (real 404 wanted)"
```
> → Live State: note `firebase.json` generated (config = pure fn of inputs; not a cloud resource).

## 3. Deploy  🟡 (outward-facing — light gate)

> 🟡 Publishes a public `https://${SITE_ID}.web.app` URL. Free tier, deploys in seconds, reverses with one command.
> Show the command, then deploy.

```bash
firebase deploy --only "hosting:${TARGET}" --project "$GCP_PROJECT" \
  -m "Source=${TAG_SOURCE} PlanVersion=${TAG_PLAN_VERSION}"   # -m = release-message provenance (valid flag, proven live)
```
> → Live State: `RELEASE` (release id from output), `SITE_URL`; `status: live` (none-branch) / `partial` (custom domain pending §4).

## 4. Custom domain — connect + DNS  🔴 ⏳  *(skipped when `DOMAIN_MODE=none`)*

> Firebase domain-connect is **console/API-driven** — there is no clean single CLI verb (the honest asymmetry vs
> [`web.cloudflare.md`](./web.cloudflare.md), where a Worker custom domain is one config line). So this follows the
> template's **`manual` provider pattern**: connect in the console, Firebase emits the records (an ownership `TXT` +
> `A` records to Firebase's anycast IPs), apply them at the delegated zone, then Firebase auto-provisions the cert.

```bash
if [ "$DOMAIN_MODE" != none ]; then
  cat <<TXT
🔴 HUMAN STEP — connect the custom domain (Firebase console is the source of the exact records):
  1. Firebase console → Hosting → site '${SITE_ID}' → "Add custom domain" → enter: ${DOMAIN_NAME}
  2. Firebase shows an ownership TXT record, then (after verification) one or more A records (its anycast IPs).
  3. Apply those records at the zone authoritative for ${DOMAIN_NAME}:
       - DNS_MODE=managed  → add them via the delegated-zone binding (domain.aws.md / domain.cloudflare.md)
       - DNS_MODE=external → add them at your DNS provider by hand
       - DNS_MODE=manual   → (default) you apply them wherever the zone lives
  4. Firebase provisions a managed cert automatically once DNS resolves (⏳ minutes–hours).
TXT
fi
```
```bash
# ✔ verify (after the human applies the records + the cert provisions)
if [ "$DOMAIN_MODE" != none ]; then
  dig +short "$DOMAIN_NAME" A | head -3                                  # expect Firebase's anycast IP(s)
  curl -s -o /dev/null -w 'domain_root=%{http_code}\n' "https://${DOMAIN_NAME}/"   # expect 200 once cert ACTIVE
fi
```
> → Live State: `CERT_STATUS: ACTIVE` once Firebase reports the domain connected; `status: live`.

## 5. Acceptance verify  ✔

```bash
BASE="https://${DOMAIN_NAME:-${SITE_ID}.web.app}"
echo "Live at: $BASE/"

# ✔ root → 200 + marker
curl -s -o /dev/null -w 'root=%{http_code}\n' "$BASE/"
curl -s "$BASE/" | grep -o "$CONTENT_MARKER" && echo "marker=found"

# ✔ deep link — branches on SITE_TYPE
if [ "$SITE_TYPE" = static ]; then
  curl -s -o /dev/null -w 'missing=%{http_code}\n' "$BASE/no-such-page"      # expect 404 (real miss)
else
  curl -s -o /dev/null -w 'deep=%{http_code}\n'    "$BASE/some/deep/route"   # expect 200 (app-shell rewrite)
fi

# ✔ HTTPS enforced — the free *.web.app cert is REAL (the gap web.gcp.md's bare IP could not close)
echo | openssl s_client -connect "${DOMAIN_NAME:-${SITE_ID}.web.app}:443" \
  -servername "${DOMAIN_NAME:-${SITE_ID}.web.app}" 2>/dev/null | openssl x509 -noout -subject -issuer 2>/dev/null
```
> → Live State: `status: live`, fill the verify rows, set `last_verified`.

## Update (idempotent reconcile)

- New site content or config → re-run §3 `firebase deploy` (a Hosting release is **atomic + declarative**; the console
  "rollback" reverts to the prior release). Live State gets the new `RELEASE`. No duplication — same `TARGET`/`SITE_ID`.
- Change `SITE_TYPE` (spa⇄static) → regenerate §2 `firebase.json` (adds/drops the `**`→`/index.html` rewrite; static
  also wants a `404.html`), redeploy §3, re-run §5's deep-link check.
- Add / change / drop a custom domain (`DOMAIN_MODE`, `DOMAIN_NAME`) → run §4 (connect in console + records) or remove
  the domain in the console + drop its DNS records. Firebase re-issues / revokes the managed cert automatically.
- Re-deploying is safe to repeat: §1's discover-or-create reuses the existing site, so the whole plan is re-runnable
  end-to-end without minting duplicates.

## Teardown — observe-first  💥

> 💥 Human go. Removes **only what this plan created** — the Hosting site. The **GCP/Firebase project is borrowed and
> is never deleted here** (contrast the web.gcp.md dogfood, which deleted a throwaway project — that was project
> lifecycle, not this plan's scope).

```bash
# custom domain first (console-driven, like the connect): remove '${DOMAIN_NAME}' from the site in the Firebase
# console, then drop its TXT/A records at the delegated zone. (No CLI verb — mirror §4's manual asymmetry.)

# delete the Hosting site (the free *.web.app goes away — proven live: the host 404s immediately after). A *named*
# site deletes cleanly; the project's DEFAULT site cannot be deleted — for that, `firebase hosting:disable` stops
# serving instead. ⚠ PERMANENT: a deleted SITE_ID "cannot be reactivated by you or anyone else" — re-standing-up
# needs a FRESH SITE_ID (the deterministic default would collide at sites:create; see the Provisioning-Inputs caveat).
firebase hosting:sites:delete "$SITE_ID" --project "$GCP_PROJECT" --force \
  || firebase hosting:disable --project "$GCP_PROJECT"
```
```bash
# ✔ verify teardown — the hostname stops serving
curl -s -o /dev/null -w 'after=%{http_code}\n' "https://${SITE_ID}.web.app/"   # expect 404 / site-not-found
firebase hosting:sites:list --project "$GCP_PROJECT" 2>&1 | grep -w "$SITE_ID" && echo "still listed?" || echo "site gone"
```
> → Live State: `status: gone`, clear `SITE_ID`/`SITE_URL`/`RELEASE`.

## Portability ledger — Firebase vs the GCP load-balancer binding

| concern | `web.gcp.md` (GCS + Cloud CDN + ext LB) | `web.gcp-firebase.md` (Firebase Hosting) |
|---|---|---|
| resources to reach the intent | ~6 (bucket, backend-bucket, url-map, proxy, forwarding-rule, cert) | ~1 (a Hosting site) |
| free preview URL | **none — a bare anycast IP, HTTP-only** ⚠️ (Asymmetry #3) | **`*.web.app` + `*.firebaseapp.com`, HTTPS, auto-cert** ✅ |
| spa app-shell fallback | LB url-map `defaultCustomErrorResponsePolicy` (needs `EXTERNAL_MANAGED`) | one `firebase.json` line: `rewrites ** → /index.html` |
| TLS | Google-managed cert on the LB (custom domain only) | automatic on every domain incl. the free host |
| custom domain | DNS A-record at the delegated zone + managed cert (CLI) | **console-driven connect** + records (🔴 manual asymmetry) |
| origin privacy | backend bucket is **public-read by design** | n/a — managed edge, no separate origin |
| time to first byte live | LB ~3–7 min to start serving ⏳ | seconds |
| teardown | delete LB front-to-back + bucket | one `hosting:sites:delete` |
| per-resource tags | GCS bucket **does** take labels (tag-after) | **none** — provenance by naming + release message |
| denial-of-wallet | LB bills **hourly** + egress | Hosting egress billed beyond the free 10 GB/mo (Blaze); no per-request WAF — mitigate with a GCP **billing budget** |

The contract is identical across all four web bindings; the **shape and the honest caveats** differ — which is exactly
the portability story. Firebase trades the LB plan's control (private-origin posture, fine-grained routing) for radical
simplicity and a free HTTPS hostname.

## Deliberately not included

- **Project / billing lifecycle** — `firebase projects:create` and project deletion are out of scope; the project is a
  borrowed platform concern (the §1 seam). A throwaway-project dogfood pattern lives in [`web.gcp.md`](./web.gcp.md).
- **Preview channels** (`firebase hosting:channel:deploy`) — ephemeral per-PR URLs are a strong fit for CI, but they're
  orthogonal to the acceptance contract (the default site's free `*.web.app` already closes the temp-URL gap). Named so
  the omission is a decision.
- **Dynamic backends** (`firebase.json` `rewrites` → Cloud Functions / Cloud Run) — this binding is **static-only** by
  intent; a server-rendered or API-backed site is a different intent (see the `service.*` plans).
- **Edge WAF / rate-limiting** — Firebase Hosting has **no edge WAF knob** (App Check is app-level, not a Hosting-edge
  control), so there is no `EDGE_PROTECTION` axis here — unlike the AWS WAF / Cloudflare Bot-Fight bindings. The
  denial-of-wallet mitigation is a **GCP billing budget** + alert, deferred to a billing plan.
- **Per-resource labels on the Hosting site** — Firebase exposes none (see "Tags & provenance"); provenance is carried
  by naming + the release message, the same documented-omission discipline the Cloudflare binding uses.
