<!-- ExtensionStart documentation bundle — all pages, concatenated. -->

<!-- ============================================= -->
<!-- Page: getting-started/connect-signin.md -->
<!-- ============================================= -->

---
title: Connect sign-in
description: Step 2 of 5. Point the kit at your own Firebase project and sign in for real.
---

This guide connects the kit to your own Firebase project so Google and
email sign-in work for real. Each step is one command or one console
screen; the [Sign-in guide](/guides/auth/) owns the detail.

## The checklist

1. **Install the Firebase CLI and log in:** `npm i -g firebase-tools`,
   then `firebase login`.
2. **Run the Firebase setup:**

   ```sh
   pnpm create extstart --firebase
   ```

   It creates or picks a project, writes the SDK config into every file
   that carries it, and prints deep links for the console steps below
   ([what it writes](/guides/auth/#the-automated-path-recommended)).
3. **Enable the sign-in providers:** Google and Email/Password (and
   Anonymous, if you use anonymous-first) in the console screen the wizard
   linked.
4. **Create the Google OAuth client** and paste its ID as
   `WXT_GOOGLE_OAUTH_CLIENT_ID` when the wizard offers
   ([redirect-URI steps](/guides/auth/#the-google-oauth-client-web-auth-flow-path)).
5. **Upgrade the project to the Blaze plan.** Step 3 deploys a Cloud
   Function, which the free Spark plan can't do. The free-tier quota
   covers development.
6. **Rebuild and reload:** restart `pnpm dev` (env values are baked in at
   build time), then reload the extension on `chrome://extensions`.

## Verify

Open the popup and click **Sign in with Google**. The "Connect your
Firebase project" notice is gone, the OAuth window completes, and your
account shows in the Account tab.

If the sign-in window opens and closes without signing you in, the OAuth
redirect URI doesn't match your current extension ID; see
[the fix](/guides/auth/#the-google-oauth-client-web-auth-flow-path). To
skip the OAuth client entirely, use the
[offscreen popup path](/guides/auth/#the-offscreen-popup-path) instead
(Chromium only, zero OAuth config).

Next: [3. Take payments](/getting-started/take-payments/).

<!-- ============================================= -->
<!-- Page: getting-started/first-feature.md -->
<!-- ============================================= -->

---
title: Build your first feature
description: Step 4 of 5. Where your code goes, and the 2-file edit that makes a feature paid.
---

This guide shows you where your code goes and the 2-file edit that makes
a feature paid. Build here any time; nothing before this step depends on
it.

## Where your code goes

The popup renders a card titled **"Your feature goes here"**. That is
`apps/extension/components/YourFeature.tsx`, a small commented component
that is yours to gut:

- Rename it freely (update the import in
  `apps/extension/entrypoints/popup/main.tsx`).
- Replace its two buttons with your real UI. Keep the primitives from
  `@extensionstart/ui` and the token color scales; both themes come free.
- Talk to the background only through `sendMessage` from
  `@/utils/messaging`. It is typed, so wrong payloads won't compile.

Unlike the demos, `YourFeature.tsx` is core: the wizard never prunes it.

## Count free actions

The default `value-first` preset raises the paywall on the 10th recorded
action. Record one after your feature does its work:

```ts
sendMessage("gateAction", { name: "your-free-action" }).catch(console.error);
```

This is the free button in `YourFeature.tsx`, verbatim.

## Make a feature premium: the 2-file edit

**File 1: `apps/extension/entrypoints/background/gates.ts`.** Add your
feature ID to the list at the top:

```ts
export const PREMIUM_FEATURES = ["premium-demo", "export-pdf"];
```

**File 2: your call site.** Ask the gate engine before running the
feature:

```ts
const decision = await sendMessage("gateFeature", { feature: "export-pdf" });
if (decision !== null) return; // the wall is already rendering; stop
// …run the premium feature…
```

The premium button in `YourFeature.tsx` does exactly this with the
`"premium-demo"` ID, so you can compare against a working example.

## What the engine handles for you

- **The wall renders itself** in the popup, sidepanel, and content-script
  surfaces; you never build wall UI.
- **Dismissals cool down.** A dismissed paywall stays quiet for 24 hours.
  Every timing number lives in `gates.ts`.
- **Sign-in chains.** Signed-out users see the sign-in wall first, then
  the paywall. Sign-in never fires standalone, which keeps you inside
  [store policy](/guides/gates/#stay-inside-chrome-web-store-policy).
- **Usage mirrors to the server.** Events flush to `POST /gate/events`
  every minute; anything with stakes reads server counters, not client
  ones.

## Delete the demos when ready

- **GateDemo** (`apps/extension/components/GateDemo.tsx`): once your own
  feature calls `gateFeature`, delete the file and its two `GateDemo`
  lines in `entrypoints/popup/main.tsx`. Don't prune the `gate` module to
  remove it; that would delete your paywall engine too.
- **Highlighter**: the content-script demo is the `content-demo` module;
  rerun `pnpm create extstart` or see the
  [module system](/guides/modules/).

Next: [5. Ship it](/publishing/first-submission/). For presets, timing
knobs, and manual wall control, see [Paywalls](/guides/gates/).

<!-- ============================================= -->
<!-- Page: getting-started/project-tour.md -->
<!-- ============================================= -->

---
title: Repo tour
description: "The monorepo map: apps, packages, backend, and how they fit together."
---

ExtensionStart is a pnpm + Turborepo monorepo. Here's the map, top to
bottom.

## `apps/extension`: the extension app

WXT + React 19 + Tailwind 4 + Firebase. WXT **generates** `manifest.json`
from `wxt.config.ts` per browser; never hand-edit a manifest.

- `entrypoints/background/`: one module per concern, imported by
  `index.ts` in a fixed order: `migrations` → `errors` → `logs` →
  `firebase` (auth) → `billing` → `gates` → `broadcasts` →
  `update-notice`. All event listeners are registered at
  the top level ([why that matters](/guides/background/)).
- Surfaces: `popup` and `sidepanel` mount the same account/settings tabs
  (no separate options page); `welcome` is the first-run tour; `content`
  holds the shadow-DOM UI: status bar, gate walls, highlighter demo;
  `offscreen` is the auth fallback. Demo surfaces `newtab` and `devtools`
  build only with `WXT_DEMO_SURFACES=true`.
- `utils/`: the typed messaging protocol, settings store, shadow-UI mount,
  DOM observer, highlight persistence, error reporting, page bridge.
- `hooks/`: `useAuth`, `useBilling`, `useTheme`.
- `site.config.ts`: every user-facing name, email, URL, and pricing-copy
  string. Rebranding starts (and mostly ends) here.

## `packages/`: framework-free cores

Each package is a framework-free core with a thin React subpath (`/react`)
where UI bindings exist. Lint enforces importing only public entrypoints.

| package | what it is |
| --- | --- |
| `core-ext` | `defineStore` / `defineStorageView` / `defineMessaging` / `defineProxyService` / `defineAlarm` / `defineMigrations` (the MV3 survival kit) plus a `/testing` chrome mock |
| `core-auth` | auth strategies (web-auth-flow default, offscreen fallback), anonymous-first linking, typed auth errors |
| `core-billing` | entitlement snapshot + states matrix, billing API client, storage-backed hooks (`useEntitlement('paid')`) |
| `gate` | trigger primitives and combinators, the gate evaluator (cooldowns/escalation/chaining), presets, and the `GateWall` UI (`/react`) |
| `ui` | Button/Card/Input/Badge/Skeleton/Dialog/Toast (CVA variants over Base UI) |

## `backend/functions`: one Hono app

A single Hono app on Cloud Functions v2, deliberately self-contained:
Firebase packs it standalone, so it can't import workspace TypeScript.
Routes: `/gateConfig`, `/billing/checkout`, `/billing/portal`,
`/billing/webhook`, `/gate/events`, `/auth/revoke`, `/errors`. The billing
core is port-based (StripeGateway / EntitlementStore / ClaimsWriter) so
alternative payment providers can implement the same contract.

## `tooling/`

- `create/`: the `create-extstart` wizard ([reference](/reference/cli/)).
- `config/`: shared ESLint/Prettier/tsconfig presets and the
  `module.schema.json` that validates module manifests.

## `module.json` everywhere

Every prunable feature module carries a `module.json` manifest declaring
its files, dependencies, env vars, and manifest permissions. The wizard's
pruner consumes these: dropping a module removes its code, deps, env
entries, and permissions together. Details in the
[module system guide](/guides/modules/).

## Commands you'll use

From the repo root:

```sh
pnpm build                # build everything
pnpm typecheck            # TypeScript, strict, workspace-wide
pnpm lint                 # ESLint, workspace-wide
pnpm turbo run test       # unit tests
```

Extension-specific root aliases (the long form
`pnpm --filter @extensionstart/extension <script>` always works too):

```sh
pnpm dev       # dev build + watch
pnpm e2e       # Playwright against the built extension
pnpm zip       # store-ready zip (also zip:firefox, zip:edge)
pnpm audit:remote-code   # scan built output
```

Backend (from `backend/functions/`): `pnpm serve` (emulators),
`pnpm firebase:deploy`, `pnpm seed:stripe`, `pnpm stripe:webhook`,
`pnpm doctor`, `pnpm test`, `pnpm test:rules`, `pnpm test:lifecycle`.

:::tip[Definition of done]
For any change you make: `pnpm typecheck` + `pnpm lint` + unit tests green,
and if it touches the extension, the e2e suite too. The e2e suite loads the
real built extension and has caught bugs unit tests can't, like a missing
manifest permission taking down the entire background.
:::

<!-- ============================================= -->
<!-- Page: getting-started/quickstart.md -->
<!-- ============================================= -->

---
title: Setup
description: Step 1 of 5. From purchase to a running extension in Chrome in about 10 minutes.
---

This guide takes you from purchase to a working extension loaded in
Chrome. No Firebase or Stripe account needed yet; the extension runs
sign-in-less out of the box.

**Prerequisites:** Chrome, Node 22, pnpm 8 (`corepack enable` picks up the
pinned version), and git.

## 1. Accept the GitHub invite

After purchase you get an invite to the private ExtensionStart repository
on the GitHub account you provided. Accept it from the email or at
[github.com/notifications](https://github.com/notifications).

## 2. Clone and install

```sh
git clone <your ExtensionStart repo URL> my-extension
cd my-extension
pnpm install
```

The install ends by running `wxt prepare` (type generation). A
warning-free install finishes on that step.

## 3. Run the setup wizard

```sh
pnpm create extstart
```

Follow the prompts: name your extension, keep all modules for now
(trimming later is easy), and accept the defaults. Two answers matter
today:

- **Env values**: press Enter through all of them; nothing is required yet.
- **Firebase setup and backend doctor**: answer **No** to both. You
  connect a backend in steps 2 and 3.

The wizard configures your clone in place and downloads nothing. It
refuses to run on a dirty git tree, so every change is reviewable with
`git diff`. Every prompt is explained in the
[wizard guide](/getting-started/wizard/); `pnpm create extstart --yes`
skips the prompts entirely.

## 4. Start the dev build

```sh
pnpm dev
```

WXT builds to `apps/extension/.output/chrome-mv3` and watches for changes.
It does not open the browser; load the extension yourself in the next step.

## 5. Load it in Chrome

1. Open `chrome://extensions`.
2. Turn on **Developer mode** (top right).
3. Click **Load unpacked** and select `apps/extension/.output/chrome-mv3`.

## 6. Verify it runs

- The **welcome tab opens by itself** as soon as the extension loads.
- Pin the icon (puzzle-piece menu) and open the **popup**. The "Connect
  your Firebase project" notice is expected; the kit ships placeholder
  Firebase config until step 2.
- If you kept `content-demo`: open any article page, select a sentence,
  and click the **Highlight** button that appears.

Next: [2. Connect sign-in](/getting-started/connect-signin/).

:::caution[The three most likely failures]
1. **"Your git working tree has uncommitted changes."** The wizard wants a
   clean baseline you can diff against. Commit or stash, then rerun.
2. **Load unpacked can't find the directory.** `.output/chrome-mv3` exists
   only after `pnpm dev` finishes its first build. Select
   `apps/extension/.output/chrome-mv3`, not `apps/extension`.
3. **The popup opens but nothing responds.** The background service worker
   crashed at startup. On `chrome://extensions`, click your extension's
   **service worker** link and read the error in that console; see
   [Background service worker](/guides/background/).
:::

<!-- ============================================= -->
<!-- Page: getting-started/take-payments.mdx -->
<!-- ============================================= -->

---
title: Take payments
description: Step 3 of 5. Wire Stripe in test mode and finish with a checkout that flips your popup to premium.
---

import DiagramMoneyFlow from "../../../components/DiagramMoneyFlow.astro";

This guide wires Stripe in test mode and ends with a real checkout from
your own paywall. How the money moves:

<DiagramMoneyFlow />

## The checklist

Run everything from `backend/functions/`, after
`firebase use <your-project-id>`. No Stripe CLI needed. Keep the order;
the webhook must exist before the first deploy
([why](/guides/billing/#one-time-setup-test-mode)).

```sh
cp .env.example .env    # non-secret knobs (return URLs, trial days)
firebase functions:secrets:set STRIPE_SECRET_KEY   # sk_test_…
pnpm seed:stripe        # creates the catalog by lookup_key (idempotent)
pnpm stripe:webhook     # registers the webhook + stores its signing secret
pnpm firebase:deploy    # ONE deploy: the api function + rules
pnpm doctor             # verifies the whole chain, points at any fix
```

Then point the extension at your backend in `apps/extension/.env`:

```sh
WXT_API_URL=https://us-central1-<project>.cloudfunctions.net/api
VITE_PREMIUM=true
```

Restart `pnpm dev` and reload the extension.

## Verify: the popup flips to premium

1. In the popup, trigger the paywall (the **Try a premium feature** demo
   button, or your own gated feature).
2. Click the wall's upgrade button. Stripe Checkout opens in a tab.
3. Pay with the test card `4242 4242 4242 4242` (any future expiry/CVC).
4. Back in the popup: the wall clears and the Account tab shows your plan,
   no reload needed. The webhook recorded your payment server-side and the
   extension picked it up.

If anything is red, `pnpm doctor` names the broken link in the chain.
Deeper checks (Firestore docs, webhook deliveries, emulator loop):
[Payments guide](/guides/billing/#verify-the-setup).

To sell a credit allowance with top-up packs instead, enable the
[credits model](/guides/billing-credits/): same setup, plus metering.

Next: [4. Build your feature](/getting-started/first-feature/).

<!-- ============================================= -->
<!-- Page: getting-started/tech-stack.md -->
<!-- ============================================= -->

---
title: Tech stack
description: Every technology in the kit, what it does here, and why it was chosen over the alternatives.
---

ExtensionStart makes opinionated choices so you do not have to. Each one
is replaceable, but together they are a stack that ships to the stores
today and stays maintainable after launch.

## Extension

| Technology | Role | Why |
| --- | --- | --- |
| [WXT](https://wxt.dev) | Extension framework | Generates per-browser MV3 manifests from one config, HMR dev builds, store-ready zips. The most actively maintained extension framework. |
| [React 19](https://react.dev) | UI | Popup, side panel, welcome tour, and content-script UI share one component model and one hooks layer. |
| [Tailwind CSS 4](https://tailwindcss.com) | Styling | Token-scale design system (`neutral`/`accent`/`success`/`warning`/`danger`); raw palettes are lint-banned so every surface stays consistent. |
| [TypeScript](https://www.typescriptlang.org) (strict) | Everywhere | `any` is a lint error. The typed messaging protocol makes background/UI contracts compile-time safe. |

## Backend and payments

| Technology | Role | Why |
| --- | --- | --- |
| [Firebase Auth](https://firebase.google.com/docs/auth) | Sign-in | The `firebase/auth/web-extension` entry point plus a web-auth-flow strategy is the combination that actually works in MV3 service workers. |
| [Cloud Functions v2](https://firebase.google.com/docs/functions) + [Hono](https://hono.dev) | API | One function, one router: checkout, webhooks, portal, usage. Free tier covers development and early production. |
| [Firestore](https://firebase.google.com/docs/firestore) | Data | Entitlements and usage counters, with security rules that deny client writes to anything purchasable. |
| [Stripe](https://stripe.com) | Payments | Checkout, customer portal, webhooks. Prices resolve by lookup key server-side; the client never sends an amount. |

## Websites

| Technology | Role | Why |
| --- | --- | --- |
| [Astro](https://astro.build) | Landing page template (`apps/site`) | Static output, zero client JS by default, strict CSP. Rebranded from one config file. |
| [Starlight](https://starlight.astro.build) | Docs template | Search, sidebar, dark mode, and llms.txt generation out of the box. |

## Quality and tooling

| Technology | Role | Why |
| --- | --- | --- |
| [pnpm](https://pnpm.io) + [Turborepo](https://turbo.build) | Monorepo | Fast installs, cached builds, one `pnpm build` for everything. |
| [Vitest](https://vitest.dev) | Unit tests | 400+ tests across the core packages and backend. |
| [Playwright](https://playwright.dev) | E2E | 12 specs run against the real built extension in Chromium, including service-worker kill/revive. |
| GitHub Actions | CI and releases | Lint, typecheck, tests, remote-code audit on every push; a version tag builds and submits store zips. |

Want to swap something? The cores are framework-free and port-based, so
the seams are explicit. Start with the [module system](/guides/modules/)
to see what is optional.

<!-- ============================================= -->
<!-- Page: getting-started/what-you-get.mdx -->
<!-- ============================================= -->

---
title: Overview
description: What ships in the kit, how the parts fit together, and the five steps from clone to store listing.
---

import { Card, CardGrid, LinkCard, Steps, FileTree } from "@astrojs/starlight/components";
import DiagramPipeline from "../../../components/DiagramPipeline.astro";
import DiagramBigPicture from "../../../components/DiagramBigPicture.astro";

You cloned a monorepo. One wizard brands it, keeps the modules you want, and connects Firebase when you are ready:

<DiagramPipeline />

<LinkCard
  title="1. Setup"
  href="/getting-started/quickstart/"
  description="Clone to a working extension in Chrome. About 10 minutes, no accounts needed."
/>

## What you get

Every part ships wired together and covered by the kit's 400+ unit tests and 12 end-to-end specs.

<CardGrid>
  <Card title="Extension app" icon="puzzle">
    Popup, side panel, welcome tour, and on-page UI. WXT generates the
    per-browser manifests; React 19 and Tailwind 4 drive the surfaces.
  </Card>
  <Card title="Sign-in" icon="approve-check">
    Google and email sign-in built for MV3, with optional anonymous-first
    accounts that keep purchases when a guest signs up.
  </Card>
  <Card title="Billing" icon="seti:shell">
    Stripe Checkout, customer portal, and webhooks. The server records who
    paid; the UI reads `useEntitlement("paid")`.
  </Card>
  <Card title="Paywalls" icon="seti:lock">
    A gate engine with store-policy-safe presets. Two message calls
    instrument a feature; the wall UI renders itself.
  </Card>
  <Card title="Backend" icon="rocket">
    One Cloud Function (Hono) for checkout, webhooks, usage counters, and
    sign-out revocation. The extension holds no secrets.
  </Card>
  <Card title="Publishing" icon="open-book">
    Store-ready zips for Chrome, Firefox, and Edge, a remote-code audit,
    and runbooks for each store's review.
  </Card>
</CardGrid>

## How the parts fit

<DiagramBigPicture />

- **The extension** renders UI and reads state from `chrome.storage`.
- **The backend** decides who paid. Stripe webhooks write entitlements
  server-side; the extension can display state but never grant it.
- **The website** hosts your landing page plus the privacy and terms pages
  the Chrome Web Store requires.

## The repo at a glance

<FileTree>

- apps/
  - extension/ WXT + React app: popup, sidepanel, background, content
  - site/ your product website (Astro): landing, privacy, terms
- packages/
  - core-ext/ MV3 primitives: storage, messaging, alarms, migrations
  - core-auth/ sign-in strategies
  - core-billing/ entitlements and billing hooks
  - gate/ paywall engine and wall UI
  - ui/ Button, Card, Dialog, and the other primitives
- backend/
  - functions/ one Hono API on Cloud Functions
- tooling/
  - create/ the setup wizard and module pruner

</FileTree>

The [Repo tour](/getting-started/project-tour/) walks each directory; the
[Tech stack](/getting-started/tech-stack/) page explains each choice.

## Ways to charge

The wizard asks how you want to charge and configures pricing UI, Stripe
products, and paywall behavior to match. All four models ship ready to
seed:

| Model                  | You sell                                        |
| ---------------------- | ----------------------------------------------- |
| Subscription (default) | Monthly and yearly plans, optional free trial   |
| Lifetime               | One payment, access forever                     |
| Subscription + credits | Monthly credit allowance plus top-up packs      |
| Credit packs           | Prepaid credits, no subscription                |

Plans live in one config array, so any mix of recurring and one-time
prices works later. See [Payments](/guides/billing/) and
[Credits](/guides/billing-credits/).

## The five steps

Each step ends with something you can verify on screen:

<Steps>

1. [Setup](/getting-started/quickstart/): clone, run the wizard, load the
   extension in Chrome.

2. [Connect sign-in](/getting-started/connect-signin/): point the kit at
   your Firebase project and sign in for real.

3. [Take payments](/getting-started/take-payments/): Stripe in test mode,
   ending with a checkout that flips your popup to premium.

4. [Build your feature](/getting-started/first-feature/): where your code
   goes, and the 2-file edit that makes it paid.

5. [Ship it](/publishing/first-submission/): the Chrome Web Store
   submission runbook.

</Steps>

## What the docs assume

You know React and TypeScript. The kit builds on
[WXT](https://wxt.dev), [Firebase](https://firebase.google.com/docs), and
[Stripe](https://docs.stripe.com) rather than teaching them; these docs
cover how the kit uses them and link out for the rest. Extension-platform
rules (service workers, permissions, store policy) are covered here,
because they are where extensions differ from web apps.

<!-- ============================================= -->
<!-- Page: getting-started/wizard.md -->
<!-- ============================================= -->

---
title: Setup wizard
description: What create-extstart asks, what it changes, and how to run it headless.
---

`create-extstart` configures **your clone in place**. It downloads
nothing, touches no files outside the repo, and refuses to run on a dirty
git tree, so every change is one `git diff` away from review and one
`git checkout` away from undo.

```sh
pnpm create extstart    # run from anywhere inside the clone
```

## What it does, in order

1. **Questionnaire**: extension name and description (written to
   `apps/extension/site.config.ts`), scope, target browsers, a
   monetization model if `billing` is kept, and a gate preset if `gate`
   is kept.
2. **The plan**: prints every file, dependency, permission, and env entry
   it will remove. Nothing changes until you confirm.
3. **Pruning**: executes the plan, driven by each module's `module.json`
   and its `module:<id>` wiring markers
   ([module system](/guides/modules/)).
4. **Marker cleanup**: strips the remaining `module:*` marker comments
   from kept files; the code stays. Opt out with `--keep-markers`
   ([Rerunning](#rerunning)).
5. **Env scaffold**: creates `apps/extension/.env` from the pruned
   `.env.example`, prompting per variable. An existing `.env` is left
   untouched.
6. **Firebase setup** (optional): creates or picks a Firebase project and
   writes its config everywhere it lives. Decline freely; it reruns
   standalone any time (below).
7. **Backend pointers**: prints the Stripe setup commands and offers to
   run the backend doctor.
8. **Verify pass**: regenerates WXT types and typechecks the pruned tree;
   `--with-tests` adds the unit suites.

## The scope question

One question before any per-module prompt: start minimal or keep
everything?

- **minimal**: what a monetized popup extension needs: `billing`, `gate`,
  and the `site` website template. Everything else is one `git checkout`
  away later.
- **everything**: keep every optional module (same as `--yes`).
- **choose**: the per-module walkthrough, in dependency order.

If unsure, keep everything; trimming later is easier than restoring from
git history.

## The optional modules

| id | what you get | drop it when |
| --- | --- | --- |
| `billing` | Stripe checkout/portal, webhook-written entitlements, pricing UI | your extension is free |
| `gate` | sign-in walls and paywalls with timing presets (requires `billing`) | you have no walls to show |
| `sidepanel` | the account/settings UI docked in Chrome's side panel | your product is popup-only |
| `site` | your extension's public website: landing, privacy policy, terms | you already have a website |
| `broadcasts` | remote banner announcements + the post-update changelog notice | you never need to reach installs between releases |
| `error-reporting` | consent-gated crash reports to your own backend (defaults to **No**) | you don't want crash telemetry |
| `content-demo` | the highlighter demo (the shadow-UI mount itself always stays) | always, once you've read its source |
| `demo-newtab` | new-tab override demo (builds only with `WXT_DEMO_SURFACES=true`) | you don't ship a new-tab surface |
| `demo-devtools` | devtools panel streaming the support log (same build flag) | you don't need it |

Core modules (auth, the extension core, UI primitives) are not removable.
Dependencies resolve automatically: keeping `gate` force-keeps `billing`;
dropping `billing` drops `gate` too. The pruner leaves `backend/**` in
place for dropped modules; the backend is one self-contained function and
unused routes are harmless.

## Firebase setup (`--firebase`)

The Firebase step runs standalone: no questionnaire, no prune, no
clean-tree requirement, safe to re-run.

```sh
pnpm create extstart --firebase
```

It creates or picks a project, writes the SDK config into every file that
carries it, and prints a deep-linked checklist of the console steps no CLI
can do (sign-in providers, Blaze plan, OAuth client). Walkthrough:
[Sign-in guide](/guides/auth/).

## Headless mode

```sh
pnpm create extstart --yes --scope minimal --name "My Ext"
pnpm create extstart --dry-run --scope minimal   # print the plan only
```

`--yes` accepts defaults with no prompts and keeps everything unless
`--scope` or `--keep` says otherwise. Full flag list:
[CLI reference](/reference/cli/).

## Rerunning

The wizard is built for one configuration pass on a fresh clone. To
experiment, run it, inspect `git diff`, and `git reset --hard` to try a
different combination.

Marker cleanup is the default, so a second pass can rebrand but no longer
prune. Run the first pass with `--keep-markers` to keep pruning open.
Adding a module back after committing means restoring its files from git
history, so keep what you are unsure about.

<!-- ============================================= -->
<!-- Page: guides/ai-agents.md -->
<!-- ============================================= -->

---
title: Building with AI agents
description: "What the kit ships for Claude Code, Cursor, and Copilot: agent instructions, tested recipes, and a Chrome DevTools MCP preset."
---

The kit ships tested surfaces for coding agents: six prompt recipes, a
shared instructions file, machine-readable docs, and an MCP preset for
debugging in a live Chrome.

## The recipes

Six tested recipes live in `.claude/commands/*.md`. Each encodes the
kit's invariants: exact file paths, the store-policy guard, the
definition of done.

| Recipe | What it does |
| --- | --- |
| `/add-feature` | Scaffold a feature off `YourFeature.tsx`, including gate wiring and the 2-file paywall edit. |
| `/add-surface` | Add a WXT entrypoint following the shadow-UI and module conventions. |
| `/change-gate-preset` | Switch paywall presets in `background/gates.ts` with the policy guard restated. |
| `/add-permission` | Add a `chrome.*` permission: manifest + `module.json` rationale + e2e guard, permission before code. |
| `/prep-store-submission` | Pre-flight a store submission: zips, remote-code audit, assets, privacy answers. |
| `/add-migration` | Change a `chrome.storage` shape with a numbered `defineMigrations` bump and tests. |

In Claude Code they are slash commands; open a session at the repo root
and type:

```sh
/add-feature summarize-page
```

In Cursor or any other agent, each file is a self-contained prompt: open
`.claude/commands/<recipe>.md`, paste the body, and replace `$ARGUMENTS`.

## What else ships

- **`AGENTS.md`**: the single source of agent instructions: architecture
  map, verified MV3 pitfalls, gate policy guard, security invariants.
  `CLAUDE.md` and `.cursor/rules/` are symlinks to it.
- **`llms.txt` + markdown mirror**: this site publishes
  [/llms.txt](/llms.txt) and every page as plain markdown, so agents can
  fetch any guide by URL.
- **Docs bundle**:
  [one concatenated markdown file](/downloads/extensionstart-docs.md)
  (and a [zip](/downloads/extensionstart-docs.zip) of the pages) for
  pasting the whole docs set into a context window.

## Debug with Chrome DevTools MCP

The repo ships a project-scope preset in `.mcp.json` for Google's
[chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp)
server, which lets an agent drive and inspect a live Chrome: read
service-worker console output, screenshot the popup, watch the
background's network calls, click through a gate wall.

**Claude Code** detects `.mcp.json` automatically and asks for approval
on first use. To add it explicitly:

```sh
claude mcp add chrome-devtools -- npx -y chrome-devtools-mcp@latest
```

**Cursor**: add the same server in Cursor Settings → MCP:

```json
{
  "mcpServers": {
    "chrome-devtools": {
      "command": "npx",
      "args": ["-y", "chrome-devtools-mcp@latest"]
    }
  }
}
```

Useful variants (append to `args`): `--isolated` for a throwaway profile,
or `--browser-url http://127.0.0.1:9222` to attach to a Chrome you
started yourself with your unpacked extension loaded; pair that with
`pnpm dev` for extension debugging.

## Agents don't get a pass

Everything in `AGENTS.md` binds agent-written code exactly as it binds
yours. The failure modes agents hit most are the
[MV3 service-worker rules](/guides/background/#the-two-rules-that-break-everything-when-violated)
and hand-writing paywall UI instead of using the gate engine. Review
agent diffs against the pitfalls.

The definition of done is the same for agents as for humans:

```sh
pnpm typecheck && pnpm lint && pnpm turbo run test
pnpm --filter @extensionstart/extension e2e
```

Don't let an agent declare victory without the e2e run.

<!-- ============================================= -->
<!-- Page: guides/auth.md -->
<!-- ============================================= -->

---
title: Sign-in
description: Google sign-in paths, email/password, anonymous-first linking, and the token rules.
---

The kit ships two Google sign-in paths, plus email/password and anonymous
sign-in, all built for extensions. Every flow runs in the background
service worker; your UI never touches Firebase, it reads who is signed in
from storage.

## Choose your Google sign-in path

Both paths are fully wired. One env var picks between them:

|  | Web auth flow | Offscreen popup |
| --- | --- | --- |
| **Browsers** | Chrome, Edge, Firefox | Chrome and Edge only |
| **Setup** | Create a Google OAuth client (about 5 console minutes) | Deploy the bundled sign-in page to Firebase Hosting |
| **How to pick it** | Set `WXT_GOOGLE_OAUTH_CLIENT_ID` | Leave `WXT_GOOGLE_OAUTH_CLIENT_ID` empty |
| **Sign-in UX** | Browser account chooser | A small popup window |
| **Watch out for** | The redirect URI embeds your extension ID, which changes if you load unpacked from a new path | One extra Hosting deploy; no Firefox |

If unsure, use the web auth flow: it covers Firefox and is the production
default. Independent of the choice:

- **Email/password** always works alongside, no OAuth client needed
  (`emailSignIn` / `emailSignUp` / `emailPasswordReset` messages).
- **Anonymous-first** (`WXT_ANONYMOUS_AUTH=true`): every install starts as
  a guest uid, and sign-in upgrades that uid in place, so purchases and
  counters survive. Turn it on when gates, counters, or purchases should
  work before sign-up.

## Set up your Firebase project

Install the Firebase CLI and sign in first: `npm i -g firebase-tools`,
then `firebase login`. Deploys also require the **Blaze plan**; the
free-tier quota covers development.

### The automated path (recommended)

```sh
pnpm create extstart --firebase
```

The wizard creates or picks a project, creates a web app, and writes its
SDK config everywhere it lives: `apps/extension/utils/firebase.ts`,
`backend/firebase-hosting/public/signInWithPopup.js`, both `.firebaserc`
files, and the URLs in `apps/extension/.env`. It then prints a
deep-linked checklist of the console steps no CLI can do (sign-in
providers, Blaze plan, OAuth client). Paste the OAuth client ID when
offered and it writes `WXT_GOOGLE_OAUTH_CLIENT_ID` too. Safe to re-run;
headless flags are in the [CLI reference](/reference/cli/).

### The manual path

1. [Firebase console](https://console.firebase.google.com): create a
   project.
2. **Add a Web App** and copy its config into
   `apps/extension/utils/firebase.ts` (the `TODO` marker). Until then the
   extension shows "Connect your Firebase project" in every surface.
3. **Authentication → Sign-in method**: enable **Google** and
   **Email/Password** (and **Anonymous** for anonymous-first). This step
   is manual even on the automated path.
4. Set `VITE_FIREBASE_HOSTING_URL=https://<project>.firebaseapp.com` in
   `apps/extension/.env`.

### The Google OAuth client (web-auth-flow path)

1. Load the extension once and copy its ID from `chrome://extensions`.
2. Google Cloud console (same project) → Credentials → Create OAuth
   client → **Web application** → authorized redirect URI:
   `https://<extension-id>.chromiumapp.org/`.
3. Set `WXT_GOOGLE_OAUTH_CLIENT_ID=<client-id>.apps.googleusercontent.com`
   in `.env`.

:::caution
The redirect URI embeds your extension ID, which changes if you load
unpacked from a different path. If sign-in suddenly opens and closes with
an error, check that the URI matches the *current* ID.
:::

### The offscreen popup path

If `WXT_GOOGLE_OAUTH_CLIENT_ID` is empty, the background completes
sign-in in an offscreen document that loads a page from your Firebase
Hosting. That page needs your config too:

1. Paste your Firebase web config into
   `backend/firebase-hosting/public/signInWithPopup.js` (the `TODO`
   marker). `pnpm create extstart --firebase` writes it for you.
2. Deploy it, from `backend/firebase-hosting/`:

   ```sh
   firebase use <your-project-id>
   firebase deploy --only hosting
   ```

3. Set `VITE_FIREBASE_HOSTING_URL=https://<your-project-id>.firebaseapp.com`
   in `apps/extension/.env`. Firebase Hosting serves the reserved
   `/__/auth/*` helpers on that origin, so the page can't run locally.

If you skip the deploy, clicking "Sign in with Google" opens a popup that
closes again silently: the page still carries placeholder config, so the
auth result never reaches your extension.

## Anonymous-first: how linking behaves

With `WXT_ANONYMOUS_AUTH=true`, every install gets a guest uid at startup:

- Gates, counters, and purchases attribute to that uid from minute one.
- Interactive sign-in **upgrades in place**: `linkWithCredential` keeps
  the uid, so entitlements and counters survive untouched.
- **Conflicts**: if the credential already belongs to an account, linking
  fails and the strategy signs into the existing account instead.
  Accounts are never merged silently; the guest's server-side data stays
  under the old uid.
- Signing out returns to a *fresh* guest session.
- Anonymous users count as signed **out** for gate identity; converting
  them is the sign-in wall's job.

## The token rules

Enforced by lint and architecture, not convention:

1. **ID tokens never leave the background.** UI and content scripts send
   messages (`billingCheckout`, `gateFeature`, …); the background attaches
   the token to the API call.
2. **Content scripts get proxied state only**: `storage.local` snapshots
   and the message bus. They never import Firebase.
3. **Single writer**: the background's `onAuthStateChanged` is the only
   writer of `storage.local.user`. UI reads storage, so every surface
   shows the same state and survives service-worker restarts.
4. **Logout everywhere**: sign-out calls `POST /auth/revoke`
   (`revokeRefreshTokens`) before clearing local state. Best-effort:
   local sign-out proceeds even if the network call fails.
5. Ephemeral or token-ish data belongs in `storage.session`, never
   `storage.sync`.

The kit imports `firebase/auth/web-extension`, not `firebase/auth`; the
standard build assumes DOM APIs a service worker doesn't have. Every
gated read awaits `authStateReady()`. The strategy handles both for you.

<!-- ============================================= -->
<!-- Page: guides/background.md -->
<!-- ============================================= -->

---
title: Background service worker
description: "Service-worker-safe messaging, state, and timers: one idiom per problem."
---

The MV3 background is an **ephemeral service worker**: Chrome kills it
after ~30 s idle, caps tasks at ~5 min, and revives it on events. The kit
ships one idiom per problem that survives this; this page is the decision
table.

## The one-idiom table

| problem | use this | ignore |
| --- | --- | --- |
| UI → background calls (request/response) | **typed messages**: the protocol in `apps/extension/utils/messaging.ts` | raw `runtime.sendMessage` |
| read-write app state shared across surfaces | **`defineStore`** (storage-backed, hydration-gated) | module globals as truth |
| background-single-writer read-only state (`user`, `entitlements`, `gateDecision`) | **`defineStorageView`**: subscribe to the storage key from the UI | UI writing those keys; reading Firebase from a surface |
| a cohesive multi-method service | **`defineProxyService`**, and only then | wrapping single functions in a service |
| timers that outlive a worker activation | **`defineAlarm`** (chrome.alarms) | `setTimeout`/`setInterval` (lint-banned in the background) |
| stored-shape changes | **`defineMigrations`**: bump the version with a numbered migration | ad-hoc "if old shape" checks in readers |

### Typed messages

Every runtime message is declared once, in `ExtensionProtocol`
(`apps/extension/utils/messaging.ts`): key = message type, param =
payload, return = response. Both ends are typed:

```ts
// add to the protocol
export interface ExtensionProtocol {
  myFeatureRun(data: { input: string }): { ok: boolean };
}

// background (top level):
onMessage("myFeatureRun", async ({ input }) => ({ ok: input.length > 0 }));

// any UI surface or content script:
const result = await sendMessage("myFeatureRun", { input: "hi" });
```

Use this for everything that is "call the background, get an answer":
sign-in, checkout, and gate checks all work this way.

### `defineStore`: read-write app state

For state any surface may write (settings is the kit's example).
`chrome.storage` is the source of truth; the in-memory cache is a
rehydratable view. Changes propagate through `storage.onChanged`:

```ts
const settings = defineStore({ key: "settings", area: "local", defaults: { theme: "auto" } });
await settings.ready;              // every context gates on hydration
settings.get().theme;
await settings.set({ theme: "dark" });
```

Call `defineStore` at the top level of the service worker; its listener
registers at define time. Use `area: "session"` for ephemeral or
token-ish data. `storage.sync` quotas: 100 KB total, 8 KB per item,
512 items, 120 writes/min.

### `defineStorageView`: background-owned state, read from the UI

`user`, `entitlements`, `gateDecision`, `broadcasts`, and `logs` are each
written by exactly one place in the background and only read everywhere
else. Surfaces subscribe to the storage key through a `defineStorageView`
(one initial read + one `storage.onChanged` subscription + a normalize
step, race-safe). The shape is `useSyncExternalStore`-compatible;
`useAuth` is the example:

```ts
const authView = defineStorageView<AuthView>(
  "user",
  (raw) => ({ loading: false, user: (raw as AuthUser | undefined) ?? null }),
  { initial: { loading: true, user: null } },
);

const useAuth = () => {
  const { user, loading } = useSyncExternalStore(authView.subscribe, authView.getSnapshot);
  // …
};
```

`useEntitlement`, `useCredits`, and `useGateDecision` are the same idiom.
If you add background-owned state, this is how the UI reads it.

### `defineProxyService`: only for multi-method services

```ts
// shared
export const [registerMathService, getMathService] =
  defineProxyService("math", () => ({ add: async (a: number, b: number) => a + b }));
// background (top level)
registerMathService();
// popup / content script
await getMathService().add(1, 2);
```

Reach for this only for a cohesive service with several methods and
shared setup. For one or two calls, use a typed message; the kit itself
ships zero proxy services.

### `defineAlarm`: the only timer that survives

```ts
defineAlarm("sync-entitlements", { periodInMinutes: 30 }, async () => { /* … */ });
```

`setTimeout`/`setInterval` don't survive worker restarts, and keepalive
intervals violate Chrome policy; both are lint-banned in the background.
Alarms have a 30-second minimum period. Short timers *within* one
activation (a debounce, a UI delay) are fine.

### `defineMigrations`: versioned storage shapes

`chrome.storage` carries data across extension updates; existing users
never get a fresh install. Any change to a stored shape means bumping the
version in `entrypoints/background/migrations.ts` with a numbered
migration:

```ts
defineMigrations({
  version: 2,
  migrations: {
    2: (data) => ({ ...migrateV1toV2(data) }),
  },
});
```

It runs at every worker start (one cheap read when up to date) and
applies pending migrations before anything hydrates.

## The two rules that break everything when violated

1. **Register all listeners synchronously at the top level.** An event
   only revives the worker if its listener was registered during the
   first synchronous evaluation of the script. A listener registered
   inside an awaited init, a `.then`, or a `setTimeout` silently misses
   the events that woke the worker. Top-level `await` is disabled for the
   same reason; await hydration *inside* handlers (a store's `ready`).
2. **Add the manifest permission before the code that needs it.** A
   missing permission makes the `chrome.*` API `undefined` at module
   scope, the throw kills the entire background module graph, and every
   message from every surface hangs forever. Declare the permission in
   `wxt.config.ts` (and the owning `module.json`) in the same change. The
   e2e suite fails fast on this.

## Background layout

The background is a module per concern
(`apps/extension/entrypoints/background/`), imported by `index.ts` in a
fixed order: `migrations` → `errors` → `logs` → `firebase` (auth) →
`billing` → `gates` → `broadcasts` → `update-notice`. Add your feature as
a new module in that list; keep its listeners top-level and its state in
a store or storage view.

<!-- ============================================= -->
<!-- Page: guides/billing-credits.md -->
<!-- ============================================= -->

---
title: Credits
description: Enable credit billing, seed the metered prices, and meter features with creditsConsume.
---

This guide sells a subscription with a monthly credit allowance plus
top-up packs. It enables the model, creates the Stripe products, and puts
your first feature on the meter.

## Enable the model

Pick it in the setup wizard:

```sh
pnpm create extstart --billing-model hybrid-credits   # or credits-only
```

`hybrid-credits` is subscription + allowance + packs; `credits-only`
sells packs without a subscription. Both default the gate preset to
[`metered`](/guides/gates/), which raises a dismissible top-up wall the
moment the balance hits 0.

## Seed the metered prices

`pnpm seed:stripe` (part of the
[billing setup](/guides/billing/#one-time-setup-test-mode)) creates
`premium_metered_monthly` (a subscription with a 1,000-credit monthly
allowance) and the packs `credits_pack_small` / `credits_pack_large`.

Credit amounts live in Stripe price metadata: `credits` on packs,
`monthly_credits` on the metered plan. The server copies them into
checkout metadata, so the webhook can grant without an extra API call.
The client never supplies an amount.

:::caution
Webhook endpoints registered before credits shipped miss `invoice.paid`,
so monthly allowance resets never arrive. `pnpm doctor` flags this:
delete the endpoint in the Stripe dashboard and rerun
`pnpm stripe:webhook`.
:::

## Meter a feature

Consume first, then work:

```ts
const result = await sendMessage("creditsConsume", { feature: "summarize" });
if (!result.ok) return; // exhausted (top-up wall is up) or offline; stop
// … do the metered work …
```

The background attaches the ID token, calls `POST /credits/consume`, and
mirrors the fresh balance into `storage.local.entitlements`. Accounts
without credits in play get `ok: true`, so instrumented features run free
under the other billing models.

## How credits move

```text
buy a pack        checkout (lookup_key) → webhook grant (+N, idempotent by event id)
monthly renewal   invoice.paid → allowance reset (packs kept + fresh allowance)
run a feature     creditsConsume message → POST /credits/consume
                  → Firestore transaction: decrement + ledger entry
                  → 402 when exhausted → the metered preset raises the top-up wall
refunded pack     charge.refunded → credits clawed back (clamped at 0)
```

Consuming spends the allowance first. Packs roll over forever; unused
allowance is replaced, not stacked, on each `invoice.paid`. Cancelling
the subscription drops the remaining allowance but keeps pack credits.

Offline consumes are denied, not queued; an offline queue would be a
client-side free-usage lever. Grants are webhook-written only, and
`/credits/consume` can only lower a balance. Each consume writes a
deterministic ledger entry, so a retried request replays its recorded
outcome instead of double-spending.

The spendable balance lives in Firestore (`customers/{uid}`:
`creditsRemaining`, `creditsAllowance`, server-only
`packCreditsRemaining`), not in Stripe: the extension needs a synchronous
"can this feature run" answer, and Stripe's credit primitives are
invoicing-oriented. Stripe stays the payment rail.

## Test it

- Firestore → `customers/{uid}`: the three credit fields, written by the
  webhook and `/credits/consume` only. Rules deny all client writes,
  including the `credit_ledger` subcollection.
- `GET /credits/balance` (Bearer token) → `{ balance, allowance }`.
- `pnpm doctor` verifies the lookup keys referenced by `site.config.ts`
  exist with credit metadata, and that the webhook subscribes to
  `invoice.paid`.
- `STRIPE_SECRET_KEY=sk_test_… pnpm test:lifecycle` runs a real
  test-clock scenario: the first invoice grants the allowance, a
  simulated month later the renewal resets it, packs survive.

Show the CreditMeter for any metered plan and never gate the balance UI
itself; users must always be able to see what they are spending.

<!-- ============================================= -->
<!-- Page: guides/billing.md -->
<!-- ============================================= -->

---
title: Payments
description: Stripe setup with the API-driven scripts, the entitlements flow, and useEntitlement.
---

This guide sets up Stripe end to end: products, webhook, one deploy, and
UI that knows who paid. How money flows:

```text
popup/sidepanel UI ── billingCheckout message ──▶ background
background ── POST /billing/checkout ──▶ Cloud Function
Cloud Function ──▶ Stripe Checkout opens in a tab
Stripe ── webhook ──▶ POST /billing/webhook
webhook ──▶ writes Firestore customers/{uid}   (the ONLY writer)
        └─▶ mirrors `paid` into custom claims
background Firestore listener ──▶ storage.local.entitlements
useEntitlement('paid') flips in every surface (no reload)
```

The extension never talks to Stripe and never holds a secret. Who paid is
recorded server-side as an *entitlement*, written only by the webhook.
Prices resolve by `lookup_key` on the server, so the client never sends a
price ID or amount, and trials are enforced server-side by `trialUsed`.

## One-time setup (test mode)

Prerequisites: the Firebase CLI and your project on the Blaze plan
([Firebase setup](/guides/auth/#set-up-your-firebase-project)).

Run everything from `backend/functions/`, after
`firebase use <your-project-id>`. No Stripe CLI needed; the scripts drive
the Stripe API directly.

```sh
cp .env.example .env    # non-secret knobs (return URLs, trial days); edit it
firebase functions:secrets:set STRIPE_SECRET_KEY   # sk_test_… (the only Stripe input)
pnpm seed:stripe        # creates the products/prices by lookup_key (idempotent)
pnpm stripe:webhook     # registers the webhook + stores the signing secret
pnpm firebase:deploy    # ONE deploy: the api function + rules, with both secrets
pnpm doctor             # verifies the whole chain end to end
```

Keep the order: `stripe:webhook` computes the function URL from your
project ID, so the webhook and its secret exist before the first deploy
binds them. What each step does:

- **`seed:stripe`** creates the catalog by lookup key: `premium_monthly`,
  `premium_yearly`, `premium_lifetime`, `premium_metered_monthly`, and the
  packs `credits_pack_small` / `credits_pack_large`. Change amounts freely
  in the Stripe dashboard; the backend resolves by `lookup_key` only. The
  keys must match `site.config.ts → pricing.plans`.
- **`stripe:webhook`** registers `…/api/billing/webhook` with the events
  the backend handles and stores the signing secret. Rerunning is a safe
  no-op.
- **`doctor`** checks project, secrets, deployed API, webhook, and seeded
  prices, and points at the fix for anything red. Run it whenever billing
  misbehaves.

Non-secret knobs live in `backend/functions/.env`:
`BILLING_SUCCESS_URL`, `BILLING_CANCEL_URL`, `BILLING_PORTAL_RETURN_URL`,
`BILLING_TRIAL_DAYS`, `BILLING_AUTOMATIC_TAX`,
`BILLING_ALLOW_PROMO_CODES`. Keep `BILLING_TRIAL_DAYS` in sync with
`site.config.ts → pricing.trialDays`; the CTA advertises one number,
Stripe enforces the other, and `pnpm doctor` flags a mismatch.

If you dropped the billing module, set `BILLING_DISABLED=true` here
instead: the deploy skips the Stripe secrets and `/billing/*` answers
`501`.

Finally, point the extension at your backend in `apps/extension/.env`:

```sh
WXT_API_URL=https://us-central1-<project>.cloudfunctions.net/api
VITE_PREMIUM=true
```

## Show who paid in your UI

```tsx
import { useEntitlement } from "@extensionstart/core-billing/react";

const { entitled, loading } = useEntitlement("paid");
```

Also available: `useCredits()` (balance, `allowance`, `exhausted`) and
`useBillingState()` (`free/trial/active/past_due/cancel_pending/lifetime`).
All read the background-written storage snapshot; no component talks to
Firestore or the billing API directly.

`useEntitlement` renders UI. The feature itself is protected by the
server: Firestore rules deny all client writes to `customers/{uid}`, so
editing extension code changes what a copy displays, never what it is
entitled to.

## Test cards

`4242 4242 4242 4242` (success), `4000 0000 0000 9995` (declined),
`4000 0027 6000 3184` (3DS challenge). Any future expiry/CVC.

## Local dev loop (emulators)

```sh
# backend/functions/.secret.local  (gitignored)
STRIPE_SECRET_KEY=sk_test_…
STRIPE_WEBHOOK_SECRET=whsec_…    # printed by `pnpm stripe:listen` on start
```

```sh
pnpm serve            # functions + firestore + auth emulators
pnpm stripe:listen    # forwards test-mode events to the emulated webhook
```

Point `WXT_API_URL` at `http://127.0.0.1:5001/<project>/us-central1/api`
while testing locally. Prefer real Checkout sessions with test cards over
`stripe trigger`; they exercise the uid-metadata path end to end.

## Verify the setup

- Firestore → `customers/{uid}`: `paid`, `plan`, `status`,
  `cancelAtPeriodEnd`, `currentPeriodEnd`, `customerId`, `updatedAt`.
- `billing_events/{eventId}`: one marker per processed event. Replaying a
  webhook returns `{"outcome":"duplicate"}` and changes nothing.
- In the extension: service-worker console →
  `await chrome.storage.local.get("entitlements")`.
- Stripe dashboard → Webhooks shows each delivery and the backend's
  response: `applied`, `duplicate`, or `ignored`.

## Automated suites

```sh
pnpm test              # unit + adapter contract suite (offline)
pnpm test:rules        # Firestore rules against the emulator (needs Java)
STRIPE_SECRET_KEY=sk_test_… pnpm test:lifecycle
                       # real Stripe test-clock lifecycle (~3 min)
```

The billing core is port-based: an alternative provider implements the
same adapter contract and must pass the same suite; see
`backend/functions/test/adapter-contract.ts`.

<!-- ============================================= -->
<!-- Page: guides/broadcasts.md -->
<!-- ============================================= -->

---
title: Announcements
description: "Reach every install in seconds: one Firestore document renders as a banner in every surface."
---

One Firestore document renders as a banner in every open surface, with no
store review in between (the kit calls these *broadcasts*). This is
remote *data*, which store policy allows;
[remote *code* is not](/publishing/rejection-codes/#purple-potassium-undisclosed-remote-code).

## Publish a broadcast

Firebase console → Firestore → `broadcasts` collection → create a doc
with any ID. The ID doubles as the dismissal key: reuse an ID and users
who dismissed it won't see it again.

| field | type | required | notes |
| --- | --- | --- | --- |
| `message` | string | yes | banner text; keep it to one line |
| `level` | string | no | `info` (default) · `warning` · `promo`; styling only |
| `link` | string | no | **https only**; renders as "Learn more" |
| `activeFrom` | number | no | epoch **ms**; hidden before this |
| `activeUntil` | number | no | epoch ms; hidden after this |
| `minVersion` | string | no | only shown on extension versions ≥ this (e.g. `"1.2.0"`) |

Delete the doc (or set `activeUntil` in the past) to retract it.

Typical uses: incident notice (`warning`), launch or discount (`promo`
with an `activeUntil`), feature announcements. `minVersion` targets
*newer* versions, not older ranges; for "update available" nudges,
announce broadly and gate the feature in code.

## How it flows

The background holds an `onSnapshot` listener on the collection (public
read-only, enforced by Firestore rules) and mirrors it into
`storage.local.broadcasts`; `<BroadcastBanner>` shows the first active
one. Dismissals are per-message and local. Offline, the last mirrored
list keeps serving. Clients can never write the collection; the rules say
`write: if false`.

## Show "what's new" after an update

The changelog notice after an extension update rides the same banner
pipeline with no Firestore involved: `runtime.onInstalled` (reason
`"update"`) writes a *local* broadcast with the ID `update-<version>` and
a link to `site.config.ts → urls.changelog`.

`<BroadcastBanner>` renders it after any server broadcast. Dismissal uses
the shared per-ID store, so it is always dismissible and shows once per
version. The pure logic lives in `apps/extension/utils/update-notice.ts`.

<!-- ============================================= -->
<!-- Page: guides/content-scripts.md -->
<!-- ============================================= -->

---
title: UI on web pages
description: Injection strategies, shadow UI that survives hostile pages, SPA navigation, and DOM observation.
---

Your UI on other people's pages runs beside code you don't control
(Chrome calls this a *content script*). This guide covers the kit's
survival utilities and the highlighter demo that proves they work.

## Choose an injection strategy

| | Declarative (manifest) | Programmatic (`chrome.scripting`) | MAIN world |
| --- | --- | --- | --- |
| When it runs | every matching page, automatically | when your code calls `executeScript` | page context, alongside page JS |
| Permissions | host permissions listed at install | `scripting` + host perms **or `activeTab`** (no install-time host warning) | same as chosen injection + `web_accessible_resources` |
| JS isolation | isolated world | isolated world | **none; the page sees and can tamper with you** |
| CSP | extension's | extension's | **the page's**; a strict page CSP can block you |
| Review impact | broad match patterns increase review time | `activeTab` is the review-friendliest | highest scrutiny |

Kit defaults:

- **Declarative + isolated world** (`entrypoints/content/`) for features
  that work passively on matching sites. Keep `matches` as narrow as your
  product allows.
- **Programmatic + `activeTab`** when the feature is user-invoked
  (toolbar click): access per click, no install-time warning.
- **MAIN world as a last resort** (reading page JS state, patching page
  APIs): you forfeit isolation and run under the page's CSP. Keep the
  MAIN-world part tiny and message back through `utils/page-bridge.ts`,
  which enforces origin, source, and schema checks; never hand-roll a raw
  `postMessage` listener.

Executed JS/WASM must ship in the bundle; remote scripts are
[an instant rejection](/publishing/rejection-codes/#purple-potassium-undisclosed-remote-code).
Remote JSON/CSS *data* is fine.

## Mount UI with `mountShadowUi`

Mount shadow-DOM UI via `mountShadowUi`
(`apps/extension/utils/shadow-ui.tsx`), never raw `createShadowRootUi`:

```tsx
await mountShadowUi(ctx, {
  name: "my-feature-ui",       // custom-element tag
  position: "overlay",          // "inline" | "overlay" | "modal"
  render: () => <MyFeature />,
});
```

WXT's shadow root gives `:host { all: initial }` isolation, but three
vectors still pierce it. The wrapper handles all three:

1. **rem units** resolve against the host page's root font size, so a
   `html { font-size: 32px }` page would double everything. The kit
   converts rem to px at build and pins `font-size: 16px` on the wrapper.
2. **CSS custom properties** inherit across the shadow boundary. The
   kit's tokens are defined on the wrapper so same-named page variables
   lose; never *read* page-defined variables.
3. **`@font-face` / `@property`** must live in the top document. WXT
   hoists them out of the shadow stylesheet at build.

The wrapper also applies **class-strategy dark mode** from the settings
store (the host page's classes never decide your theme) and exposes a
`useShadowContainer()` portal target. Never portal overlays to
`document.body`; they would land outside the shadow styles.

For complex editors that need full *event* isolation (keyboard shortcuts,
focus), use WXT's iframe mode (`createIframeUi`); you pay with an extra
document and messaging.

## Handle SPA navigation

Never monkey-patch `history.pushState`. Listen instead:

```ts
ctx.addEventListener(window, "wxt:locationchange", ({ newUrl }) => {
  /* re-run idempotent mount/apply work here */
});
```

## Observe the DOM

`observeDom(ctx, callback, options)` (`apps/extension/utils/observe.ts`)
wraps `MutationObserver` with the three rules that keep observers from
melting busy pages:

- **Debounced batches** (default 250 ms): bursts collapse into one
  trailing callback.
- **Self-mutation guard**: pass your own shadow hosts via `ignore` so
  your DOM writes don't re-trigger you.
- **Disconnect on invalidation**: auto-disconnects when the extension
  updates or reloads while the tab lives on.

Make the callback idempotent and cheap. Anything heavy belongs behind the
debounce or in the background.

## Walkthrough: the highlighter demo

The `content-demo` module exercises everything above on hostile pages.
Trace it in `apps/extension/components/content/Highlighter.tsx` and
`apps/extension/utils/highlights.ts`:

1. **Select text** on a matching page: a shadow-UI button appears,
   mounted with `mountShadowUi`. A page with `html { font-size: 32px }`
   and `* { all: revert }` can't distort it; the e2e suite asserts that.
2. **Click Highlight**: the selection is wrapped using DOM APIs only,
   never `innerHTML` (lint-banned kit-wide).
3. The highlight persists per page in `storage.local.highlights` and
   counts as a gate action; after enough actions the paywall raises on
   the page itself.
4. **SPA navigation and DOM mutations** re-anchor highlights via an
   idempotent `applyAll()` driven by `wxt:locationchange` and
   `observeDom`.
5. **Click a highlight** to remove it.

For your real product: drop the `content-demo` module and keep the
pattern. Mount with `mountShadowUi`, react to `wxt:locationchange`,
observe with `observeDom`, write DOM with DOM APIs.

<!-- ============================================= -->
<!-- Page: guides/gates.md -->
<!-- ============================================= -->

---
title: Paywalls & free limits
description: "Sign-in walls and paywalls: presets, instrumenting features, and staying inside Chrome Web Store policy."
---

Pick a preset and list your premium features; the same wall then renders
in every surface, and you never build wall UI. Every timing number lives
in one file: `apps/extension/entrypoints/background/gates.ts`.

## Pick a preset

| preset | paywall appears | use when |
| --- | --- | --- |
| `value-first` (default) | premium-feature moment, or after 10 actions / 3 days | most products |
| `day-zero` | first session (dismissible) | strong day-0 conversion focus |
| `metered` | credit balance exhausted (+ premium moments) | usage products with [credit billing](/guides/billing-credits/) |
| `silent` | only when you call `open("paywall")` | maximum review safety |

The wizard writes your preset into `background/gates.ts`; switching later
is a one-line edit:

```ts
defineGates({
  gates: valueFirst({ premiumFeatures: ["export-pdf"], paywallAfterActions: 10 }),
})
```

Every preset except `silent` accepts: `premiumFeatures` (feature IDs that
raise the paywall immediately), `paywallAfterActions` (default 10),
`paywallAfterDays` (default 3), `cooldownMinutes` (quiet period after a
dismissal, default 24 h), and `escalateAfterDismissals` (default 0 =
never; leave it that way for core features). The sign-in wall never fires
on its own in any preset; it appears only chained, when an action needs an
account.

## Gate a feature

Two message types instrument your product. Both return `null` for
"proceed"; anything else means the wall is up and the action should stop:

```ts
import { sendMessage } from "@/utils/messaging";

// Before running a premium feature:
const decision = await sendMessage("gateFeature", { feature: "export-pdf" });
if (decision) return; // wall is already rendering everywhere

// Counting free usage toward the actions threshold:
await sendMessage("gateAction", { name: "highlight" });
```

Then add the feature ID to `premiumFeatures` in `background/gates.ts`.
The engine handles the wall UI, cooldowns, and the sign-in chain.

**Metered (credit-billed) features** use `creditsConsume` instead:
consume first, then work:

```ts
const result = await sendMessage("creditsConsume", { feature: "summarize" });
if (!result.ok) return; // out of credits; the top-up wall is up
```

The `metered` preset reads the real balance: the wall fires the instant a
consume hits 0 and clears when a purchase or allowance reset raises it.
Enforcement stays server-side; the wall is conversion UX. See
[Credits](/guides/billing-credits/).

For manual control (the `silent` preset), use
`sendMessage("gateOpen", { gateId: "paywall" })`. The dev tools in the
Settings tab (dev builds) reset all local gate state.

## Why did this wall appear?

Read `background/gates.ts` top to bottom; it is the complete explanation:

1. `PREMIUM_FEATURES`: any `gateFeature` call with a listed ID raises the
   paywall immediately.
2. The `valueFirst({ ... })` call expands to two gates: **signin**
   (trigger `manual()`, so it can never fire on its own) and **paywall**
   (premium moment, 10th action, or day 3, with `requires: "signin"` and
   `dismissible: true`).
3. A wall did *not* appear because the user is paid, the gate is inside
   its post-dismissal cooldown, or the requirement is already satisfied.
   Anonymous guests count as signed **out**.

## Stay inside Chrome Web Store policy

CWS's single-purpose policy expects your listed functionality to work on
a fresh install. Review accounts are fresh installs; a wall they can't
pass reads as bait-and-switch, a rejection class. The kit's guardrails:

1. **Core value stays usable pre-wall.** Gate premium extras; never put
   the action from your listing's first sentence behind a paywall. If the
   whole product is paid, say so in the listing; that is allowed, hiding
   it is not.
2. **Walls are dismissible by default.** `escalateAfterDismissals` is
   opt-in and belongs only on non-core features.
3. **No wall at install.** No preset triggers a non-dismissible wall on
   day 0. Don't hand-write one.
4. **Sign-in never fires standalone**; chained only.
5. **For a review-sensitive product**, use `silent`: nothing fires unless
   you call it.

## The server-side mirror

Walls are conversion UX; the features they gate are enforced by
entitlements and credits on the server. Gate events flush (via a 1-minute
alarm) to `POST /gate/events`, which increments `usage/{uid}` in
Firestore. Anything with stakes (trial-once, credit balances, abuse caps)
reads those server counters, never client numbers.

## Remote config

`GET /gateConfig` serves flags and values as remote *data*, which store
policy allows; [remote *code* is not](/publishing/rejection-codes/#purple-potassium-undisclosed-remote-code).
Wire those values into gate thresholds with a config transform before
`defineGates`.

<!-- ============================================= -->
<!-- Page: guides/modules.md -->
<!-- ============================================= -->

---
title: Module system
description: How module.json manifests drive the pruner, and how to add a module of your own.
---

The kit ships as the full repo; the setup wizard prunes the modules you
don't keep. What makes that safe is a contract: every prunable module
carries a **`module.json` manifest** declaring everything it owns (code,
npm dependencies, env vars, manifest permissions, docs). One declaration
removes all of it together.

Manifests live at the package root for workspace packages
(`packages/gate/module.json`) or under
`apps/extension/modules/<id>/module.json` for app-level modules,
validated against `tooling/config/module.schema.json`.

## Anatomy of a manifest

```json
{
  "$schema": "../../tooling/config/module.schema.json",
  "id": "billing",
  "title": "Billing & entitlements",
  "description": "Stripe checkout/portal routes, webhook-written entitlements, useEntitlement('paid').",
  "files": ["packages/core-billing/**", "backend/functions/src/billing/**"],
  "dependsOn": ["auth"],
  "npmDependencies": { "@extensionstart/core-billing": "workspace:*" },
  "env": [{ "name": "WXT_API_URL", "description": "deployed Functions base URL" }],
  "permissions": [],
  "wiring": ["apps/extension/entrypoints/background/index.ts"],
  "docs": []
}
```

- `files` globs are repo-root-relative; a module can own files outside
  its package.
- `dependsOn` is by module ID. The resolver keeps dependencies of any
  kept module and drops dependents of any dropped one.
- Core modules set `"removable": false`; the wizard never offers to prune
  them.
- Env vars and permissions listed by several modules are pruned only when
  no kept module lists them.
- `permissions` is why the generated manifest shrinks when you prune:
  each module declares the `chrome.*` permissions it needs, and a smaller
  permission surface means a faster store review.

## Wiring markers

A module's code often touches shared files it doesn't own: the background
import order, the messaging protocol, surface roots. Those touchpoints
carry marker comments so the pruner can strip them:

- **Line marker**: a `// module:<id>` suffix (or `{/* module:<id> */}` in
  JSX) removes that line when `<id>` is dropped.
- **Block marker**: everything from `module:<id>:start` through
  `module:<id>:end` (inclusive) is removed. Blocks of different modules
  may nest.

Each manifest lists the shared files carrying its markers under `wiring`.
The pass criterion: any prune combination leaves `pnpm typecheck` and
`pnpm lint` green with zero dangling imports.

## Backend files stay

Manifests list backend files as documentation of ownership, but the
pruner leaves `backend/**` in place. The Hono app is one self-contained
function and unused routes are harmless. Delete them manually if you want
a minimal backend.

## Add your own module

1. Create the manifest (`apps/extension/modules/<id>/module.json` for an
   app-level feature) with `id`, `title`, `description`, and `files`
   globs for everything the module owns.
2. Tag each touchpoint in shared files with a `// module:<id>` line
   marker or a `module:<id>:start` / `end` block, and list those files
   under `wiring`.
3. Declare `npmDependencies`, `env` (names must exist in
   `apps/extension/.env.example`), `permissions`, and `dependsOn`.
4. Keep the manifest in sync: adding a file, dependency, env var, or
   permission means updating `module.json` in the same change.
5. Prove it prunes cleanly:

   ```sh
   pnpm create extstart --dry-run --keep none    # your module in the plan?
   ```

   Then, on a scratch branch, run a real prune that drops your module and
   check `pnpm typecheck` and `pnpm lint` stay green.

<!-- ============================================= -->
<!-- Page: guides/theming.md -->
<!-- ============================================= -->

---
title: Theming
description: "The token system: rebrand by swapping one color scale, and the conventions that keep every surface consistent."
---

One set of design tokens covers every surface: popup, sidepanel, welcome,
and the on-page UIs. To rebrand, swap one color scale; no component edits
needed.

## Swap one scale to rebrand

The scales live in `apps/extension/assets/tailwind.css` (the `@theme`
block). The stock Tailwind palette is disabled, so raw palette utilities
(`bg-blue-600`, `text-gray-500`) don't compile, and lint bans them too.
Component code references intent, never hue:

| scale | role |
| --- | --- |
| `neutral` | the only gray: surfaces, borders, text |
| `accent` | brand + every primary action; **swap this scale to rebrand** |
| `success` | paid/active states, confirmations |
| `warning` | past-due, cautions |
| `danger` | destructive actions, errors |

Replace the eleven `--color-accent-*` oklch values with your brand's
scale (Tailwind v4's palette reference has ready-made scales):

```css
/* apps/extension/assets/tailwind.css: swap these for your brand */
@theme {
  --color-accent-50: oklch(0.97 0.014 254.604);
  --color-accent-100: oklch(0.932 0.032 255.585);
  /* … 200–900 … */
  --color-accent-950: oklch(0.282 0.091 267.935);
}
```

Every button, link, ring, and wall across every surface follows.

Tinted chips and banners pair `{scale}-50` background / `{scale}-800`
text / `{scale}-200` border in light mode; `{scale}-950` / `{scale}-200`
/ `{scale}-800` in dark (see `BroadcastBanner` for the reference
implementation).

## Dark and light: always both

Dark mode is class strategy, driven by the settings store: `auto` follows
the OS; light/dark override it. Write `dark:` variants as you author and
check every new component in both themes.

Content-script shadow UIs get the theme class on their shadow wrapper,
never from the host page (`mountShadowUi` handles this).

## Typography

- **InterVariable**, bundled locally in `assets/fonts/`; no CDN fonts.
- Headings are semibold (set in the base layer); body is regular.
- Numbers always get `tabular-nums` (prices, credit counts, timers) so
  digits don't jiggle.
- Scale in practice: `text-base` headings inside surfaces, `text-sm`
  body, `text-xs` secondary. Popup surfaces are dense; stay at or below
  `text-lg` outside the welcome page.

## Radius

| radius | used for |
| --- | --- |
| `rounded-lg` | controls: buttons, inputs, selects |
| `rounded-xl` | cards, panels, option rows |
| `rounded-full` | pills and avatars **only** |

## Motion

Fades and small translates only, 150–200 ms, hard cap 300 ms, no spring
or bounce. Extension surfaces open and close constantly; motion that
draws attention twice a minute is noise. The one sanctioned entrance:
`animate-in fade-in slide-in-from-bottom-4` on transient chrome (status
bar, toasts).

## Spacing and layout

4 px grid (the Tailwind default). Surfaces: `p-4` sections, `space-y-4`
between blocks, `gap-2`/`gap-3` inside rows. Popup min-width is
`min-w-90` (360 px).

## Components

Primitives come from `@extensionstart/ui`: Button, Card, Input, Badge,
Skeleton, Dialog, Toast. Never hand-roll a `<button>` or badge in app
code; extend via `className`, merged with `cn()`. Focus styles are built
into the primitives; custom interactive elements must match.

Two practical notes:

- New Tailwind class sources outside the extension app need an `@source`
  line in `assets/tailwind.css`.
- In shadow UIs, rem is converted to px at build (rem would resolve
  against the host page's root font size); see
  [UI on web pages](/guides/content-scripts/).

<!-- ============================================= -->
<!-- Page: index.mdx -->
<!-- ============================================= -->

---
title: Introduction
description: "ExtensionStart documentation: from clone to a published, monetized browser extension."
---

import { CardGrid, LinkCard } from "@astrojs/starlight/components";

ExtensionStart is a starter kit for paid browser extensions: a monorepo
with sign-in, Stripe billing, paywalls, and store publishing wired up and
tested. These docs walk the same path you will: from clone to a published,
monetized extension on Chrome, Firefox, and Edge.

New here? Read the [Overview](/getting-started/what-you-get/) to see what
is in the box, then follow the five setup steps in order.

## Popular pages

<CardGrid>
  <LinkCard
    title="Setup"
    href="/getting-started/quickstart/"
    description="Clone, run the wizard, load the extension in Chrome. About 10 minutes."
  />
  <LinkCard
    title="Take payments"
    href="/getting-started/take-payments/"
    description="Stripe in test mode, ending with a checkout that flips your popup to premium."
  />
  <LinkCard
    title="Paywalls and free limits"
    href="/guides/gates/"
    description="Pick a preset, list your premium features, stay inside store policy."
  />
  <LinkCard
    title="Publish to the Chrome Web Store"
    href="/publishing/first-submission/"
    description="Developer account, listing, privacy tab, and what review expects."
  />
</CardGrid>

## How the docs are organized

- **Getting started**: the five-step path from clone to store listing.
  Follow it in order; each step ends with something you can verify.
- **Working with the codebase**: one guide per subsystem. Sign-in,
  payments, credits, paywalls, on-page UI, theming, announcements, the
  background service worker, and the module system.
- **Going to production**: submission runbooks and policy references for
  all three stores.
- **Reference**: CLI flags, environment variables, and the messaging
  protocol.

## Give the docs to your AI agent

- [extensionstart-docs.md](/downloads/extensionstart-docs.md): every page
  in one Markdown file, sized for a context window.
- [llms.txt](/llms.txt) · [llms-full.txt](/llms-full.txt): machine-readable
  indexes.
- Append `.md` to any page URL for raw Markdown, or use the
  "Copy as Markdown" button next to each page title.

The [AI agents guide](/guides/ai-agents/) covers the prompt recipes and
Chrome DevTools MCP preset that ship in the repo.

## FAQ

### Do I have to use Firebase?

Firebase (Auth, Firestore, Cloud Functions) is the supported, wired-up
path, and its free tier covers development. The billing core is
port-based, so adapting it to another backend is a contained task rather
than a rewrite.

### Which browsers are supported?

Chrome and Edge (MV3), plus Firefox. The build produces per-browser
bundles, Chromium-only APIs are feature-guarded, and CI lints the Firefox
build on every commit.

### How does billing work under the hood?

Stripe Checkout and the customer portal, driven by one Cloud Function.
Webhooks write entitlements server-side; the extension reads a
storage-backed snapshot. The server is the authority.

### Can I charge subscriptions, one-time payments, or credits?

All of them. Four monetization models ship ready to seed into Stripe; the
wizard configures the one you pick. See [Payments](/guides/billing/) and
[Credits](/guides/billing-credits/).

### What if I get stuck?

Every subsystem has its own guide in the sidebar. For anything else, email
[support@extensionstart.com](mailto:support@extensionstart.com).

<!-- ============================================= -->
<!-- Page: publishing/edge.md -->
<!-- ============================================= -->

---
title: Edge (Partner Center)
description: Microsoft Edge Add-ons basics, and the 72-day API-key expiry that breaks CI.
---

Microsoft Edge runs Chromium, so the Edge package is the Chrome build
with its own store pipeline. Registration on the
[Microsoft Partner Center](https://partner.microsoft.com/dashboard/microsoftedge/)
is free.

## Build and submit

```sh
pnpm build:edge   # local test build
pnpm zip:edge     # store zip
```

Manual flow: Partner Center → Microsoft Edge program → new extension →
upload the zip from `apps/extension/.output/` → fill listing and privacy
fields (the same content as [CWS](/publishing/privacy-disclosures/)) →
submit. Reviews land within days; plan for up to a week.

CI flow: `pnpm submit` targets Edge when `EDGE_PRODUCT_ID`,
`EDGE_CLIENT_ID`, and `EDGE_API_KEY` are present in the environment
(`submit:dry` validates credentials without uploading). The product ID
comes from the Partner Center listing URL after you create the listing
once by hand.

## Rotate the API key every 72 days

**Edge Add-ons API keys expire 72 days after creation.** Symptoms: Chrome
and Firefox publish fine, the Edge upload starts failing with an auth
error, and nothing about your code changed.

- Generate keys at Partner Center → **Publish API** (this shows the
  expiry date).
- Put a reminder ~10 weeks out, or rotate the key as part of every
  release cycle if you ship less often than quarterly.
- Rotating is instant: generate a new key and update the `EDGE_API_KEY`
  secret in CI; the client ID stays stable.

## Edge-specific notes

- The Chrome zip is technically accepted, but use the `zip:edge` build;
  WXT targets Edge explicitly and keeps the output separate.
- Edge users can install from the Chrome Web Store too, but a native
  listing installs without the "allow extensions from other stores"
  friction and gets Edge's own discovery surface.
- Staged rollout: Partner Center supports gradual percentages on updates;
  verify availability for your account tier.

<!-- ============================================= -->
<!-- Page: publishing/firefox-amo.md -->
<!-- ============================================= -->

---
title: Firefox (AMO)
description: Building for Firefox, the AMO source-code requirement, and a reviewer-notes template.
---

Firefox distribution goes through addons.mozilla.org (AMO). The kit
builds a Firefox-specific package from the same codebase, with the
Chromium-only permissions and APIs filtered out.

## Build and package

```sh
pnpm build:firefox   # local test build
pnpm zip:firefox     # AMO upload zips
```

Load a test build via `about:debugging` → This Firefox → **Load Temporary
Add-on**. Before submitting, set your own add-on ID in `wxt.config.ts`;
the Firefox manifest branch carries a placeholder
`browser_specific_settings.gecko.id` with a `TODO` to replace it.

What is different in the Firefox build (handled per-browser in
`wxt.config.ts`):

- Permissions are filtered to `storage`, `tabs`, `identity`, `alarms`;
  `offscreen` and `sidePanel` don't exist on Firefox and AMO lint rejects
  unknown permissions.
- No offscreen document means Google sign-in on Firefox requires
  `WXT_GOOGLE_OAUTH_CLIENT_ID` (the web-auth-flow path; see
  [Sign-in](/guides/auth/)).
- Chrome-only APIs are guarded in code (`browser.sidePanel?.…`).

## Upload source code with the zip

AMO reviews are human and stricter than CWS about build pipelines.
Because the code is bundled and minified, you must upload the **source
code** alongside the extension zip, plus instructions that reproduce the
build.

`pnpm zip:firefox` produces **both zips** in `apps/extension/.output/`:
the extension package and a `-sources.zip` with the source tree. Upload
the sources zip when the AMO flow asks "Do you need to submit source
code?" → Yes.

## Reviewer notes template

Paste into "Notes for Reviewers" and adjust versions:

```text
This extension is built from the included sources with WXT (Vite).

Build environment:
- Node 22, pnpm 8 (pinned via the packageManager field in package.json)

Reproduce the build:
1. unzip the sources
2. pnpm install --frozen-lockfile
3. pnpm zip:firefox
4. compare .output/*-firefox.zip with the submitted package

Notes:
- No remote code: all executed JS ships in the bundle. Remote requests
  fetch JSON data only (feature flags, announcements) from our own
  Firebase backend.
- Minification is Vite's standard esbuild pass; no obfuscation.
- Sign-in uses browser.identity.launchWebAuthFlow with Firebase Auth
  (OAuth implicit flow; no client secret in the bundle).
```

If you kept billing: add one line saying premium features require a
subscription and include a **test account** (email/password sign-in is
the easiest to hand a reviewer).

## AMO-specific gotchas

- **Versions are immutable**: you can't replace an uploaded version. Fix
  and bump.
- CI already runs AMO's linter (`addons-linter`) against the Firefox
  build, so lint-class surprises surface before you submit.
- AMO asks for a privacy policy whenever data leaves the machine (auth
  and error reports both qualify); reuse the
  [privacy disclosures](/publishing/privacy-disclosures/) content.
- Updates auto-publish after review; there is no staged rollout on AMO.

<!-- ============================================= -->
<!-- Page: publishing/first-submission.md -->
<!-- ============================================= -->

---
title: First Chrome Web Store submission
description: Developer account, listing, privacy tab, permission minimization, and what to expect from review.
---

The runbook for your first Chrome Web Store (CWS) submission. Work top to
bottom; nothing here assumes a previous publication.

## 1. Create the developer account

1. Sign in to the
   [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole)
   with the Google account that will own the listing.
2. Pay the one-time $5 registration fee and verify your email.
3. Turn on **two-factor authentication**. A phished publisher account can
   push a malicious update to every install; the account is a bigger
   target than the code. Longer term, publish from CI with scoped API
   credentials, never from laptops.

## 2. Build the zip

```sh
pnpm zip
pnpm audit:remote-code
```

`pnpm zip` builds production and writes the store-ready zip to
`apps/extension/.output/` (the filename embeds the version). The audit
scans the built output for remote-code patterns before review does.

## 3. Minimize permissions

Every permission adds install-warning friction and review time. Pruned
modules already took their permissions with them; review two items
yourself:

- **The content script matches `https://*/*`** out of the box. That broad
  pattern exists only for the highlighter demo and is the kit's single
  biggest review-time item. Narrow `matches` to the sites your product
  operates on, or switch to programmatic injection with `activeTab`.
- **Every remaining permission needs a one-line justification** in the
  privacy tab:

| permission | why it's there | drop it when |
| --- | --- | --- |
| `storage` | every store/state primitive | never (core) |
| `identity` | Google sign-in (`launchWebAuthFlow`) | you remove Google sign-in |
| `offscreen` | offscreen sign-in fallback | you set `WXT_GOOGLE_OAUTH_CLIENT_ID` and delete the fallback |
| `alarms` | service-worker-safe timers | you remove the gate module and use no alarms |
| `sidePanel` | the sidepanel surface | you remove the sidepanel entrypoint |
| `tabs` | the OAuth window-focus workaround | you drop the offscreen fallback |

Never request `<all_urls>`, `webRequest`, `cookies`, `history`, or
`management` "for later": each is a review escalator; add them with the
feature that needs them.

## 4. Create the listing

Store listing tab: name, description, at least one 1280×800 or 640×400
screenshot, the 128×128 icon, category, and language
(`pnpm store-assets` generates promo-image scaffolding). Write the
description around your **single purpose**: CWS expects one narrow
purpose, and the listed functionality must work on a fresh install
([how the kit guarantees that](/guides/gates/#stay-inside-chrome-web-store-policy)).

## 5. Fill the privacy practices tab

Every field must be filled before the submit button enables:

1. **Single purpose**: one sentence.
2. **Permission justifications**: one per permission; use the table above.
3. **Data usage**: paste the kit's answers from
   [Privacy disclosures](/publishing/privacy-disclosures/).
4. **Certifications**: the three compliance checkboxes;
   [Privacy disclosures](/publishing/privacy-disclosures/#the-certification-checkboxes)
   covers why the unmodified kit satisfies each.
5. **Privacy policy URL**: required as soon as you collect any user data
   (with auth enabled, you do). Host one at the URL in
   `site.config.ts → urls.privacy`.

## 6. Submit

- Typical review is **hours to a few days**. Broad host permissions and
  new developer accounts stretch that, up to a few weeks. Don't plan a
  launch on review completing overnight.
- **Deferred publish** lets you get approved first and press publish when
  ready.
- **Staged rollout** requires a large existing install base, so it won't
  apply to submission #1.
- If rejected, the email names a code; look it up in
  [Rejection codes](/publishing/rejection-codes/).

## 7. After approval

- Monitor the listing for unexpected versions.
- Later releases can go through CI: `pnpm submit` wraps
  `publish-browser-extension` and reads store credentials from the
  environment (`submit:dry` validates credentials without uploading).
- [Announcements](/guides/broadcasts/) reach installs while a review is
  pending.

<!-- ============================================= -->
<!-- Page: publishing/privacy-disclosures.md -->
<!-- ============================================= -->

---
title: Privacy disclosures
description: What the kit actually collects, mapped to the CWS privacy questionnaire, with copy-paste answers.
---

The Chrome Web Store privacy tab asks what user data your extension
collects, category by category. This page maps the kit's actual
collection footprint to those categories, with answers you can paste.

:::danger[These answers describe the unmodified kit]
The moment you add collection (analytics, page-content features, new
APIs), the answers change, and disclosures that undersell what you
collect are a
[rejection/takedown class](/publishing/rejection-codes/#purple-lithium--purple-nickel-data-use-disclosures).
Re-audit this page before every submission.
:::

## What the kit collects

| data | where it goes | notes |
| --- | --- | --- |
| Auth profile (email, name, avatar, uid) | Firebase Authentication | only when the user signs in; anonymous-first creates an account without personal info |
| Billing state (plan, status, Stripe customer ID) | Stripe + Firestore `customers/{uid}` | only on purchase; card data never touches your code (Stripe Checkout hosts it) |
| Usage counters (feature/action counts, keyed by uid) | Firestore `usage/{uid}` | gate events; no page URLs or content |
| Crash reports (error message, stack, surface, version) | Firestore `errors` | **consent-gated** toggle, keyed by a random install ID, never a uid |

What the kit does **not** collect, by construction: no analytics, no
browsing history, no host-page content, no keystrokes, no location.
Support logs never upload; they live in `storage.session` and export only
when the user copies them.

## The questionnaire, category by category

| CWS category | collect? | why |
| --- | --- | --- |
| Personally identifiable information | **Yes** | email and name via sign-in |
| Health information | No | n/a |
| Financial and payment information | **No** | purchases happen on Stripe-hosted pages; the extension never sees payment details |
| Authentication information | **Yes** | Firebase credentials/tokens (background-only; never exposed to pages) |
| Personal communications | No | n/a |
| Location | No | no location APIs; no IP collection by the extension |
| Web history | No | no page URLs are collected |
| User activity | **Yes, minimal** | in-extension usage counters and, with consent, crash reports. No network monitoring, no clicks/keystrokes on pages |
| Website content | No | content scripts read the page only to render local features; nothing page-derived is transmitted |

Template free-text justification (adapt the product name):

> We collect an account profile (email, name) through Firebase
> Authentication when the user signs in, subscription status through
> Stripe when the user purchases, and in-extension feature-usage counts
> tied to the account. Crash reports (error message and stack trace only,
> no browsing data) are collected only if the user opts in from Settings.
> We do not collect browsing history, page content, or any data from the
> websites the user visits.

## The certification checkboxes

You must certify all three; the unmodified kit satisfies them:

1. **"I do not sell or transfer user data to third parties, apart from
   the approved use cases"**: data goes only to your own Firebase project
   and Stripe (a service provider processing payments).
2. **"I do not use or transfer user data for purposes that are unrelated
   to my item's single purpose"**: auth, billing, usage gating, and
   opt-in crash reporting all serve the extension's function.
3. **"I do not use or transfer user data to determine creditworthiness or
   for lending purposes"**: trivially true.

## Pre-submission checklist

- [ ] Privacy policy is live at the URL in `site.config.ts → urls.privacy`
      and describes the four data types above.
- [ ] "Share crash reports" default matches your policy copy (flip the
      default in `apps/extension/utils/settings.ts` if your policy is
      strictly opt-in).
- [ ] You haven't added an analytics or error SDK without updating this
      mapping (a script-injecting SDK is also a
      [remote-code rejection](/publishing/rejection-codes/#purple-potassium-undisclosed-remote-code)).
- [ ] If you dropped modules, drop the matching disclosures: no `billing`
      → no Stripe rows; no `error-reporting` → no crash-report rows.
- [ ] Firefox/Edge: reuse this content in AMO's data-collection section
      and Partner Center's privacy fields; the facts are identical.

<!-- ============================================= -->
<!-- Page: publishing/rejection-codes.md -->
<!-- ============================================= -->

---
title: CWS rejection codes
description: "The Chrome Web Store rejection classes: what triggers each, how the kit prevents it, and how to recover."
---

Chrome Web Store rejection emails cite color-plus-element codes ("Blue
Argon", "Purple Potassium"). This page maps the classes you are most
likely to meet to what triggered them and what to do.

:::caution
Google occasionally renames or splits these codes; the official list
lives in the CWS "Troubleshooting Chrome Web Store violations" docs. The
trigger/prevention/recovery guidance below is stable; check the current
CWS docs when reading a rejection email.
:::

## Blue Argon: obfuscated code

- **Triggers**: shipped code the reviewer can't read (obfuscators,
  string-encrypted payloads). Minification is allowed; obfuscation is not.
- **Kit prevention**: no obfuscation anywhere in the pipeline; standard
  Vite minification only.
- **Recovery**: remove the obfuscation (often a dependency doing it),
  rebuild, resubmit. To protect logic, move it server-side.

## Purple Potassium: undisclosed remote code

- **Triggers**: fetching and executing JS/WASM at runtime: remote
  `<script src>`, `eval` of downloaded strings, script-injecting
  analytics SDKs.
- **Kit prevention**: lint bans `eval`, `new Function`, and innerHTML
  sinks. `pnpm audit:remote-code` scans the **built** output (every
  JS/HTML file plus the manifest CSP) and runs in CI, so a dependency
  that starts shipping dynamic code fails the pipeline, not review.
  Remote *data* (gateConfig, broadcasts) is the sanctioned alternative.
- **Recovery**: find the offender with the audit script, bundle the code
  locally or cut the dependency, resubmit.

## Red Nickel / Red Titanium: metadata quality

- **Triggers**: keyword-stuffed or misleading title/description,
  irrelevant screenshots, duplicate listings, unverified claims.
- **Kit prevention**: none possible technically. Write the description
  around your single purpose, keep screenshots current, don't enumerate
  competitor names.
- **Recovery**: rewrite the named listing fields and resubmit; these are
  usually fast re-reviews.

## Yellow Magnesium: broken functionality

- **Triggers**: the extension doesn't work for the reviewer: errors on a
  fresh install, features that require an account the reviewer doesn't
  have, a paywall the reviewer can't get past.
- **Kit prevention**: the e2e suite runs against the real built
  extension, which catches fresh-install breakage like a
  [background killed by a missing permission](/guides/background/#the-two-rules-that-break-everything-when-violated).
  The gate presets keep core functionality usable pre-wall and every wall
  dismissible ([policy guard](/guides/gates/#stay-inside-chrome-web-store-policy)).
- **Recovery**: reproduce on a fresh Chrome profile with the exact store
  zip. If your product requires an account or purchase, say so in the
  listing and provide **test credentials in the review notes**.

## Blue Lithium: unjustified permissions

- **Triggers**: permissions the reviewer can't map to visible
  functionality: broad host patterns, `tabs` "just in case", missing
  justifications in the privacy tab.
- **Kit prevention**: modules declare their own permissions and pruning
  removes them; every entry has a written rationale
  ([first submission §3](/publishing/first-submission/#3-minimize-permissions)).
  The kit's one flag: narrow the demo content script's `https://*/*`
  match before submitting.
- **Recovery**: remove the permission or add the justification, whichever
  is true. Narrowing host permissions is the most common fix.

## Purple Lithium / Purple Nickel: data-use disclosures

- **Triggers**: collecting user data without matching disclosures: a
  privacy tab that misses data your code collects, a dead privacy-policy
  URL, disclosures inconsistent with Limited Use.
- **Kit prevention**: the kit's collection footprint is small and fully
  mapped, with paste-ready answers in
  [Privacy disclosures](/publishing/privacy-disclosures/).
- **Recovery**: align the disclosures with reality (or the code with the
  disclosures), confirm the privacy-policy URL resolves, resubmit.

## When a rejection doesn't fit

- Re-read the email; it names the policy section and often the specific
  file or listing field.
- Check the current CWS troubleshooting docs for the cited code; this
  page covers the common classes, not the full catalog.
- **Appeal** via the developer dashboard when you believe the rejection
  is a false positive; include reproduction notes.
- If the listing was *taken down* rather than rejected, respond quickly;
  repeated violations escalate toward account suspension.

<!-- ============================================= -->
<!-- Page: publishing/troubleshooting.md -->
<!-- ============================================= -->

---
title: Submission troubleshooting
description: Common Chrome Web Store submission errors and their fixes.
---

Quick fixes for the errors that stop a submission before or during
review. For post-review rejections, see
[Rejection codes](/publishing/rejection-codes/).

## Upload and dashboard errors

| symptom | fix |
| --- | --- |
| "An error occurred: please try again later" on upload | Upload the file produced by `pnpm zip` from `.output/`, not a hand-made archive. `manifest.json` must sit at the zip **root**, not inside a folder. |
| "Invalid manifest" / manifest key warnings | Never hand-edit `manifest.json`; it is generated. Fix the source in `wxt.config.ts` and rebuild. Uploading a Firefox zip to CWS also lands here: browser-specific keys belong only in the Firefox build. |
| "Cannot submit: privacy practices incomplete" | Every permission needs a justification and every data question an answer. Work through [Privacy disclosures](/publishing/privacy-disclosures/); the submit button stays disabled until all fields are filled. |
| Icon errors | The manifest icon set must include 128×128. Regenerate assets rather than resizing by hand. |
| "Version already exists" | Bump `version` in `wxt.config.ts` and rebuild; each upload needs a strictly greater version. |

## Stuck in review

- **Pending for more than ~2 weeks**: broad host permissions
  (`https://*/*` from the demo content script; narrow it), newly
  registered accounts, and first submissions all extend review. The
  "contact support" form in the dashboard occasionally unsticks month-old
  items.
- **Review keeps asking about a permission**: your justification doesn't
  connect the permission to user-visible functionality. Rewrite it as
  "user does X → extension needs Y", or remove the permission.

## Approved but broken

- Test the **exact store zip**: unzip the file you uploaded and load it
  unpacked in a fresh profile. Dev output and the production zip differ
  (env, minification).
- Check env values baked into the build: `WXT_API_URL` and the Firebase
  config compile in at build time; a zip built with a stale `.env` points
  at the wrong backend. Rebuild with production values and submit an
  update.
- The background dying on some installs is usually a permission or
  API-availability difference; see the
  [background guide](/guides/background/#the-two-rules-that-break-everything-when-violated).

## Account-level problems

- Emails asking you to "verify your item" by granting OAuth access are
  **phishing**; the store never asks for that. Report and delete.
- Payments-profile or identity-verification holds block publishing until
  resolved in the dashboard; start that process before launch day.

<!-- ============================================= -->
<!-- Page: reference/cli.mdx -->
<!-- ============================================= -->

---
title: CLI reference
description: Every create-extstart flag, with the semantics that matter.
---

`create-extstart` configures your clone in place. Run it from
anywhere inside the clone; it finds the repo root itself. The full
walkthrough is in the [wizard guide](/getting-started/wizard/).

import { Tabs, TabItem } from "@astrojs/starlight/components";

```sh
pnpm create extstart [options]
```

## Options

| flag | meaning |
| --- | --- |
| `--name "My Ext"` | extension name (written to `site.config.ts`, consumed by the manifest) |
| `--description "..."` | one-line description |
| `--scope minimal\|everything` | module scope preset: `minimal` keeps `billing`, `gate` (plus dependencies) and the `site` template; `everything` keeps all optional modules (the `--yes` default) |
| `--keep a,b,c` | optional modules to **keep**; the others are pruned. `all` (the default with `--yes`) and `none` also work. Overrides `--scope` |
| `--browsers chrome\|chrome+firefox` | target browsers |
| `--billing-model <name>` | monetization model: `subscription` (default), `lifetime-only`, `hybrid-credits`, or `credits-only`. Rewrites `site.config.ts` pricing and defaults the gate preset; ignored when billing is dropped ([payments guide](/guides/billing/)) |
| `--preset <name>` | gate preset: `value-first`, `day-zero`, `metered`, or `silent`. Ignored (with a note) when billing/gates are dropped |
| `--keep-markers` | keep the `module:*` wiring markers so the wizard can prune again later; by default they're stripped after the run (see below) |
| `--firebase` | run **only** the Firebase setup step: no questionnaire, prune, or clean-tree requirement. Writes the project config everywhere it lives; safe to re-run. See the [Sign-in guide](/guides/auth/) |
| `--firebase-project <id>` | Firebase project id to use non-interactively (with `--firebase --yes`) |
| `--firebase-create` | with `--firebase-project`: create that project. Headless runs never create cloud resources without this explicit flag |
| `--yes`, `-y` | non-interactive: accept flags/defaults, no prompts |
| `--dry-run` | print the full plan, change nothing |
| `--force` | run even with a dirty git working tree |
| `--skip-verify` | skip the typecheck verify pass |
| `--with-tests` | also run unit tests in the verify pass |
| `--help`, `-h` | usage text |

## Module IDs for `--keep`

`billing`, `gate`, `sidepanel`, `site`, `broadcasts`, `error-reporting`,
`content-demo`, `demo-newtab`, `demo-devtools`. Convenience aliases:
`gates` → `gate`, `billing-stripe` → `billing`, `errors` → `error-reporting`.

Dependencies resolve automatically and loudly: `--keep gates` force-keeps
`billing` (with a printed note); dropping `billing` drops `gate` and
`demo-newtab` too.

## Semantics worth knowing

- **Dirty-tree refusal**: the wizard deletes and rewrites files, so it
  requires a clean `git status`; you review the result with `git diff`
  and undo with git. `--force` overrides; `--dry-run` never needs it.
- **`--yes` without `--scope`/`--keep`** keeps every optional module.
- **Marker cleanup is the default**: after pruning, every remaining
  `module:*` marker comment is stripped from the kept files (code stays).
  Pass `--keep-markers` if you want to re-run the wizard to prune more
  later; without them, a second pass can only rebrand, not prune.
- **`--billing-model` defaults the gate preset** (`metered` for the credit
  models); an explicit `--preset` wins.
- **`--dry-run --keep none`** prints the maximal prune plan, the fastest
  way to see everything the module system owns.
- An existing `apps/extension/.env` is never overwritten by the env
  scaffold. The `--firebase` step is the one exception: it updates only
  `VITE_FIREBASE_HOSTING_URL`, `WXT_API_URL` (and, if you paste one,
  `WXT_GOOGLE_OAUTH_CLIENT_ID`) in place; every other line is preserved.
- Exit code is non-zero when the wizard aborts, the plan is declined, or
  the verify pass fails typecheck, so it's safe to use in CI.

## Examples

<Tabs>
  <TabItem label="Interactive">
    ```sh
    # Full walkthrough with prompts
    pnpm create extstart
    ```
  </TabItem>
  <TabItem label="Headless (CI)">
    ```sh
    # A monetized popup extension, nothing extra
    pnpm create extstart --yes --name "My Ext" --scope minimal

    # A billing-enabled product targeting Chrome only
    pnpm create extstart --yes --name "My Ext" \
      --keep billing,gates,broadcasts --preset value-first

    # Hybrid credits: subscription + allowance + top-up packs (metered preset)
    pnpm create extstart --yes --name "My Ext" --scope minimal \
      --billing-model hybrid-credits

    # Free extension, no walls, Chrome + Firefox
    pnpm create extstart --yes --name "My Ext" --keep broadcasts \
      --browsers chrome+firefox
    ```
  </TabItem>
  <TabItem label="Firebase setup">
    ```sh
    # Interactive: create/pick a project, write its config everywhere
    pnpm create extstart --firebase

    # Headless: use an existing project
    pnpm create extstart --firebase --yes --firebase-project my-ext-prod

    # Headless: create the project too (explicit opt-in)
    pnpm create extstart --firebase --yes \
      --firebase-project my-ext-prod --firebase-create
    ```
  </TabItem>
  <TabItem label="Dry run">
    ```sh
    # Explore what a prune would do; changes nothing
    pnpm create extstart --dry-run --scope minimal
    pnpm create extstart --dry-run --keep none

    # What the Firebase step would run and write; needs no firebase CLI
    pnpm create extstart --firebase --dry-run
    ```
  </TabItem>
</Tabs>

<!-- ============================================= -->
<!-- Page: reference/env.md -->
<!-- ============================================= -->

---
title: Environment variables
description: Every variable the kit reads, extension-side and server-side, and the one rule about secrets.
---

## The one rule: the extension bundle is public

Everything the extension app reads at build time (any `WXT_`- or
`VITE_`-prefixed variable) is **compiled into the shipped extension** and
readable by anyone who downloads it from the store. Treat extension env as
*configuration*, never secrets:

- OK in extension env: Firebase web config, hosting URLs, feature flags.
- NEVER in extension env: Stripe secret keys, service-account JSON, webhook
  signing secrets, any API key that grants data access. Those live only in
  the backend (Secret Manager / function env).

## Extension variables (`apps/extension/.env`)

Created from `.env.example` by the setup wizard. All are build-time.

| variable | module | purpose |
| --- | --- | --- |
| `WXT_GOOGLE_OAUTH_CLIENT_ID` | auth | Google OAuth client ID (Web application type; redirect `https://<ext-id>.chromiumapp.org/`). Set → sign-in uses the chrome.identity web-auth-flow (+ `getAuthToken` fast path on Chrome). Empty → offscreen `signInWithPopup` fallback. |
| `VITE_FIREBASE_HOSTING_URL` | auth | Firebase Hosting URL used by the offscreen sign-in fallback (`https://<project>.firebaseapp.com`). |
| `WXT_ANONYMOUS_AUTH` | auth | `true` = anonymous-first: every install gets a guest uid immediately; sign-in upgrades it in place (uid preserved). Default `false`. |
| `WXT_API_URL` | billing, gate, error-reporting | Deployed Cloud Functions base URL (`https://us-central1-<project>.cloudfunctions.net/api`). Checkout/portal, gate events, and error reports all post under it. |
| `VITE_PREMIUM` | billing | `true` shows premium UI (pricing, portal, status). Plans/copy live in `site.config.ts`; amounts and trials are server-side. |
| `WXT_DEMO_SURFACES` | demo-newtab, demo-devtools | `true` builds the optional demo entrypoints (branded new tab + devtools panel). Default `false` so the standard build never takes over the user's new tab. |

The Firebase web config itself is **not** env; paste it into
`apps/extension/utils/firebase.ts` (the `TODO` marker).

### File layering (WXT/Vite dotenv order)

Loaded from `apps/extension/`; later files override earlier ones:

1. `.env`: base values (untracked; created from `.env.example`)
2. `.env.local`: personal overrides (untracked)
3. `.env.[mode]`: per-mode, e.g. `.env.development` (trackable)
4. `.env.[mode].local`: personal per-mode overrides (untracked)

For browser-specific values, prefer branching on
`import.meta.env.BROWSER` (or per-browser manifest fields in
`wxt.config.ts`) over separate `.env.chrome`/`.env.firefox` files.

### Prefixes

- `WXT_*`: preferred for new variables (exposed on `import.meta.env`).
- `VITE_*`: also exposed; parts of the kit still use it.
- Unprefixed variables are **not** available to app code; use that
  deliberately for build-machine-only values.

Keep `.env.example` exhaustive, and keep each module's `module.json` `env`
list in sync; that's what lets the pruner remove template entries with
their module.

## Server-side (backend/functions)

Secrets, set once per project:

```sh
firebase functions:secrets:set STRIPE_SECRET_KEY       # sk_…
firebase functions:secrets:set STRIPE_WEBHOOK_SECRET   # whsec_… (pnpm stripe:webhook sets this for you)
```

Non-secret knobs, plain env on the function:

| variable | purpose |
| --- | --- |
| `BILLING_SUCCESS_URL` | checkout success return page (https) |
| `BILLING_CANCEL_URL` | checkout cancel return page (https) |
| `BILLING_PORTAL_RETURN_URL` | customer-portal return URL |
| `BILLING_TRIAL_DAYS` | card-free trial length; `0` = none. Display copy in `site.config.ts → pricing.trialDays` should match; the server stays the authority |
| `BILLING_AUTOMATIC_TAX` | `"true"` enables Stripe Tax on checkout |
| `BILLING_ALLOW_PROMO_CODES` | `"false"` hides the promo-code field (default on) |

Credit-based billing needs **no env of its own**: credit amounts live in
Stripe price metadata (`credits` on packs, `monthly_credits` on the metered
plan; `pnpm seed:stripe` sets them), and `site.config.ts → pricing.plans`
decides which model is sold. See
[the credits model](/guides/billing-credits/).

For local emulator runs, the same two secrets go in
`backend/functions/.secret.local` (gitignored); see the
[payments guide](/guides/billing/#local-dev-loop-emulators).

<!-- ============================================= -->
<!-- Page: reference/messaging.md -->
<!-- ============================================= -->

---
title: Messaging protocol
description: "The typed runtime messages between UI surfaces and the background: the kit's entire internal API."
---

Every runtime message in the extension is declared once, in
`apps/extension/utils/messaging.ts`, as the `ExtensionProtocol` interface:
key = message type, parameter = payload, return type = response. Both ends
are typed end to end; never use raw `runtime.sendMessage`.

```ts
import { sendMessage, onMessage } from "@/utils/messaging";

// caller (any surface):
const user = await sendMessage("signIn", undefined);

// handler (background, top level):
onMessage("signIn", async () => { /* … */ });
```

The bus ignores foreign messages by envelope marker; handlers validate
their own payloads.

## Message summary

### Auth (UI → background)

| message | payload | returns | notes |
| --- | --- | --- | --- |
| `signIn` | none | `AuthUser` | interactive Google sign-in (web-auth-flow, offscreen fallback) |
| `signOut` | none | none | signs out of Firebase, clears the stored user, revokes refresh tokens server-side |
| `emailSignIn` | `{ email, password }` | `AuthUser` | |
| `emailSignUp` | `{ email, password }` | `AuthUser` | |
| `emailPasswordReset` | `{ email }` | none | sends the reset email |

### Billing (UI → background) – `billing` module

| message | payload | returns | notes |
| --- | --- | --- | --- |
| `billingCheckout` | `{ lookupKey }` | `{ url }` | the background does the API call and opens the Stripe tab, so the flow survives the popup closing |
| `billingPortal` | none | `{ url }` | customer-portal session |
| `creditsConsume` | `{ feature, amount? }` | `{ ok, balance, reason? }` | metered features: server-side transactional decrement, balance mirrored to storage. `ok: false` = stop the feature; see [the credits model](/guides/billing-credits/) |

### Gates (UI/content → background) – `gate` module

| message | payload | returns | notes |
| --- | --- | --- | --- |
| `gateFeature` | `{ feature }` | `GateDecision \| null` | `null` = proceed; a decision means the wall is up (already published to every surface; just stop the action) |
| `gateAction` | `{ name? }` | `GateDecision \| null` | counts usage toward action thresholds |
| `gateOpen` | `{ gateId }` | `GateDecision \| null` | manually raise a wall (`"signin"` / `"paywall"`) |
| `gateDismiss` | `{ gateId }` | none | dismiss the active wall (starts its cooldown) |
| `gateReset` | none | none | dev tools: wipe local gate counters/dismissals/active wall |

### Infrastructure

| message | payload | returns | notes |
| --- | --- | --- | --- |
| `log` | `LogEntry` | none | any context → background: append to the support-log ring buffer |
| `offscreenGetAuth` | none | offscreen auth payload | background → offscreen document only |

## Extending the protocol

1. Add the method signature to `ExtensionProtocol` in
   `apps/extension/utils/messaging.ts`.
2. Register the handler in the owning background module, **at the top
   level** of the file (see
   [background patterns](/guides/background/#typed-messages)).
3. Call it with `sendMessage` from any surface. The compiler enforces
   payload and response types on both ends.

If the message belongs to a prunable module, wrap the protocol lines in
that module's wiring markers (`// module:<id>:start` … `end`) so pruning
keeps the file compiling; see the [module system](/guides/modules/).

## What deliberately isn't a message

- **State reads.** Surfaces don't ask the background for state; they read
  the `storage.local` snapshots (`user`, `entitlements`, `gateDecision`,
  `broadcasts`) via hooks (`useAuth`, `useEntitlement`,
  `useGateDecision`). Messages are for *actions*.
- **Backend calls with tokens.** UI and content scripts never hold ID
  tokens; they send a message, and the background attaches the token to
  the API call.
