<!-- 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.
---

Step 2 of 5. Point the kit at your own Firebase project so Google and
email sign-in work for real. Each step here 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 ([details](/guides/auth/#the-manual-path-what-the-script-automates)).
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 4 of the journey
   deploys a Cloud Function, which Spark can't do. The free-tier quota
   covers development
   ([details](/guides/auth/#setting-up-your-firebase-project)).
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); the
[sign-in guide](/guides/auth/#choose-your-google-sign-in-path) compares the
two paths.

Once you're signed in, continue to
**[3. Take payments](/getting-started/take-payments/)**: seed Stripe and
watch that account become a paying customer.

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

---
title: Build your first feature
description: Where your code goes, and the 2-file recipe that makes a feature paid.
---

Step 4 of 5. Setup is done: the extension runs, sign-in works, payments
clear. This page is where *your* product starts: where your code goes, and the
**2-file edit** that makes it 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's
`apps/extension/components/YourFeature.tsx`, a small, heavily 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; you get both themes and
  consistent styling for free.
- Talk to the background only through the `sendMessage` helper from
  `@/utils/messaging` (typed, so wrong payloads won't compile), never raw
  `chrome.runtime.sendMessage`.

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

## Make an action count toward the paywall

Free actions should still *count*: the default `value-first` preset raises
the paywall on the 10th recorded action. Record one line, fire-and-forget,
after your feature does its work:

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

That's the free button in `YourFeature.tsx`, verbatim minus the error
message.

## Make a feature premium – the 2-file recipe

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

```ts
/** The demo premium feature (see GateDemo) — replace with your real ones. */
export const PREMIUM_FEATURES = ["premium-demo"];
```

becomes

```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…
```

Done. The premium button in `YourFeature.tsx` does exactly this with the
`"premium-demo"` id, so it gates out of the box; swap in your own id once
File 1 lists it.

## What happens automatically

You never build wall UI. The engine handles the rest:

- **The wall renders itself.** The background publishes the decision to
  `storage.local.gateDecision`; the already-mounted `<GateOverlay>` shows
  the same wall in the popup, sidepanel, and content-script surfaces.
- **Dismissals cool down.** A dismissed paywall stays quiet for 24 hours
  (`cooldownMinutes: 60 * 24` in `gates.ts`; every timing number lives in
  that one file).
- **Sign-in chains.** Signed-out and anonymous users see the sign-in wall
  first, then the paywall. Sign-in never fires standalone, which is
  [CWS-policy-safe by design](/guides/gates/).
- **Usage mirrors to the server.** Recorded events flush to
  `POST /gate/events` every minute, keeping uid-keyed counters in
  Firestore. Client-side counts can be wiped or forged.

## Delete the demos when ready

Two demos exist purely to show the paths above:

- **GateDemo** (`apps/extension/components/GateDemo.tsx`): the "Try a
  premium feature" button. It ships with the `gate` module, so don't prune
  that module to remove it; you'd delete your paywall engine too. Once
  your own feature calls `gateFeature`, delete the file and its two
  `GateDemo` lines in `entrypoints/popup/main.tsx`.
- **Highlighter**: the content-script demo. This one the wizard *can*
  prune: it's the `content-demo` module. Rerun `pnpm create extstart`
  or see the [module guide](/guides/modules/).

## Next steps

Your feature is gated, and sign-in and payments are already wired from
steps 2–3, so the wall can actually convert. Next,
[5. Ship it](/publishing/first-submission/) covers submitting to the
Chrome Web Store.

For the gate engine's full surface (presets, timing knobs, policy
guardrails, manual wall control), see the [Paywalls guide](/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: Run it
description: Step 1 of 5. From purchase to a running extension in Chrome in about 10 minutes.
---

Step 1 of 5 in [the journey](/getting-started/what-you-get/): purchase to a
working extension loaded in Chrome, in about 10 minutes. No Firebase or
Stripe account needed today: the extension runs sign-in-less out of the
box, and the backend waits until steps
[2](/getting-started/connect-signin/) and
[3](/getting-started/take-payments/).

**Prerequisites:** Chrome, Node 22, pnpm 8 (`corepack enable` picks up the
pinned version), and git (the wizard uses it as a safety net).

## 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
```

`pnpm install` ends by running `wxt prepare` (type generation for the
extension app). A warning-free install finishes on that step.

## 3. Run the setup wizard

```sh
pnpm create extstart
```

The wizard configures your clone in place. It downloads nothing. It
refuses to run on a dirty git tree, so every change it makes is reviewable
with `git diff`. You'll be asked, in order:

1. **Extension name** and one-line description.
2. **Scope**: minimal, everything, or choose modules (the kit's optional
   features) one by one. Keep everything for now; trimming later is easy.
3. **Target browsers**: `chrome` or `chrome+firefox`.
4. **Monetization model** and **gate preset** (if you kept billing/gates):
   accept the defaults.
5. **The plan**: review what it's about to change, then "Apply this plan?".
6. **Env values**: press Enter through all of them; nothing here is
   required today.
7. **Firebase setup** and **backend doctor** offers: answer **No** to both.
   You don't have a backend yet.
8. **Verify pass**: regenerates WXT types and typechecks the pruned tree.

Every prompt is explained in the [wizard guide](/getting-started/wizard/).
To skip the prompts, `pnpm create extstart --yes --keep all` accepts
every default; see the [CLI reference](/reference/cli/).

## 4. Start the dev build

```sh
pnpm dev
```

WXT builds the extension to `apps/extension/.output/chrome-mv3` and watches
for changes. It doesn't open the browser; load it 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**. You'll see the
  "Connect your Firebase project" notice. That's expected, since the kit
  ships placeholder Firebase config. Sign-in unlocks once you wire your own
  project in the [Sign-in guide](/guides/auth/).
- If you kept `content-demo`: open any article page, select a sentence, and
  click the **Highlight** button that appears. That's the content-script
  shadow UI working.

Your extension runs, with zero backend setup. Next,
[2. Connect sign-in](/getting-started/connect-signin/) points it at your
own Firebase project. Then [3. Take payments](/getting-started/take-payments/)
makes the upgrade button real.

:::caution[The three most likely failures]
1. **The wizard exits with "Your git working tree has uncommitted
   changes."** It rewrites files and wants a clean baseline you can diff
   against. Commit or stash, then rerun (or pass `--force`).
2. **Load unpacked can't find the directory.** `.output/chrome-mv3` only
   exists after step 4 has finished its first build. Make sure `pnpm ...
   dev` is running and select `apps/extension/.output/chrome-mv3`, not
   `apps/extension`.
3. **The popup opens but nothing responds.** The background service worker
   crashed at startup. `chrome://extensions` → your extension → click the
   **service worker** link and read the error in that console. (Classic
   cause after manual edits: a `chrome.*` API used without its manifest
   permission; see [How the background works](/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";

Step 3 of 5. Wire Stripe in test mode, then complete 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. The order matters
([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
```

Each step is explained in the
[Payments guide](/guides/billing/#one-time-setup-test-mode). 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 from
   [step 2](/getting-started/first-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 server recorded your payment (the *entitlement*)
   and the extension picked it up.

If anything is red, `pnpm doctor` names the broken link in the chain. Deeper
verification (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.

Payments work end to end. Next:
**[4. Build your feature](/getting-started/first-feature/)**, then
**[5. Ship it](/publishing/first-submission/)** when it's ready for the
Chrome Web Store.

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

---
title: Start here
description: One wizard turns the ExtensionStart kit into your branded, monetized extension; five steps then take it to a live store listing.
---

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

ExtensionStart is a paid starter kit for monetized browser extensions.
You clone a private repo, and four steps take you to a live store listing:

<DiagramPipeline />

Along the way you'll need three accounts: Google (for Firebase), Stripe,
and a Chrome Web Store developer account ($5, once). Each step tells you
when.

## Four ways to charge

The wizard asks how you want to charge. All four models ship ready to
seed into Stripe:

<CardGrid>
  <Card title="Subscription">
    Monthly and yearly plans plus a lifetime tier, with an optional free
    trial. The default.
  </Card>
  <Card title="Lifetime">
    One payment, access forever. Good for tools without running costs.
  </Card>
  <Card title="Subscription + credits">
    A monthly credit allowance plus top-up packs. The model AI tools use:
    heavy users pay more.
  </Card>
  <Card title="Credit packs">
    Prepaid credits, no subscription. Pay again only when they run out.
  </Card>
</CardGrid>

Your choice sets the pricing UI, the Stripe products, and the paywall
behavior. These are presets, not constraints: plans live in one config
array, and any mix of recurring and one-time prices works. See
[Payments](/guides/billing/) and [Credits](/guides/billing-credits/).

## The journey

The docs walk the same path as five steps. Each ends with something you
can verify on screen:

1. **[Run it](/getting-started/quickstart/)**: clone, wizard, extension
   loaded in Chrome. No backend needed yet.
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.

<LinkCard
  title="1. Run it"
  href="/getting-started/quickstart/"
  description="Purchase to a working extension loaded in Chrome, in about 10 minutes."
/>

## The big picture

Three deployable parts, one wizard behind them all:

<DiagramBigPicture />

- **The extension**: popup, side panel, welcome tour, and in-page UI,
  built on MV3-safe patterns and covered by 350+ tests.
- **The backend**: one Cloud Function that decides who paid, with sign-in,
  Stripe checkout, and webhooks included. The extension never holds a
  secret.
- **The website**: an Astro landing page plus the privacy and terms pages
  the Chrome Web Store requires.

For the full map of the repo, see the [Repo tour](/getting-started/project-tour/).

<!-- ============================================= -->
<!-- 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 never
downloads templates and never touches files outside the repo. Run it from
anywhere inside the clone:

```sh
pnpm create extstart        # resolves to the workspace CLI in tooling/create
```

It has zero runtime dependencies and refuses to run on a dirty git tree, so
every change it makes is one `git diff` away from review and one
`git checkout` away from undo.

## What it does, in order

1. **Questionnaire**: extension name and description (written to
   `apps/extension/site.config.ts`, which the manifest reads), the scope
   question below, target browsers, a monetization model if `billing` is
   kept, and a gate preset if `gate` is kept.
2. **The plan**: prints every file to delete, marker line to strip, and
   npm dependency, manifest permission, and `.env.example` entry to remove.
   Nothing changes until you confirm.
3. **Pruning**: executes the plan, driven by each module's `module.json`
   manifest and its `module:<id>` wiring markers (comments that tag each
   module's lines; see the [module system guide](/guides/modules/)).
4. **Marker cleanup**: strips the remaining `module:*` marker comments
   from kept files; the code inside stays. Opt out with `--keep-markers`
   ([Rerunning](#rerunning)).
5. **Env scaffold**: creates `apps/extension/.env` from the pruned
   `.env.example`, prompting per variable; Enter keeps the shown value. An
   existing `.env` is left untouched.
6. **Firebase setup** (optional, needs the `firebase` CLI): creates or
   picks a Firebase project and writes its config into every file that
   carries it. Decline freely; it reruns standalone any time (below).
7. **Backend pointers**: prints the Firebase/Stripe setup commands (the
   API-driven scripts in `backend/functions`) and offers to run the backend
   doctor.
8. **Verify pass**: regenerates WXT types (`wxt prepare`) and runs
   `pnpm typecheck` on the pruned tree; `--with-tests` adds the unit
   suites.
9. **Launch checklist**: the 4 steps from build to first checkout.

## The scope question

Before any per-module prompt, the wizard asks one question: **start minimal
or keep everything?**

- **minimal**: what a monetized popup extension needs and nothing else:
  `billing`, `gate`, and the `site` website template (your Chrome Web Store
  listing needs a privacy-policy page). The side panel, broadcasts, error
  reporting, and all demo surfaces are dropped; each is one `git checkout`
  away later.
- **everything**: keep every optional module (the same as `--yes`). Prune
  later by re-running the wizard (see `--keep-markers`).
- **choose**: the per-module walkthrough, in dependency order.

Headless, this is `--scope minimal` / `--scope everything`; an explicit
`--keep` list overrides `--scope`.

## The optional modules

| id | what you get | drop it when |
| --- | --- | --- |
| `billing` | Stripe checkout/portal, webhook-written entitlements, pricing UI, `useEntitlement` | 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 (`sidePanel` permission, opened from the toolbar icon) | your product is popup-only |
| `site` | your extension's public website (Astro): landing page, CWS-ready privacy policy, terms, changelog | 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 (opt-in; the wizard defaults this one to **No**) | you don't want crash telemetry |
| `content-demo` | the highlighter demo feature (the shadow-UI mount itself always stays) | always, once you've read its source; it's a teaching demo |
| `demo-newtab` | branded new-tab override demo (requires `billing`; built only with `WXT_DEMO_SURFACES=true`) | you don't ship a new-tab surface |
| `demo-devtools` | devtools panel streaming the support log (built only with `WXT_DEMO_SURFACES=true`) | you don't need it |

Core modules (auth, the extension core, UI primitives) aren't removable.
Dependencies resolve automatically: keeping `gate` force-keeps `billing`;
dropping `billing` drops `gate` (and `demo-newtab`) too.

One deliberate exception: the pruner leaves `backend/**` in place even for
dropped modules. The backend is one self-contained Cloud Function and
unused routes are harmless.

## Firebase setup (`--firebase`)

The Firebase step also runs standalone. It skips the questionnaire and
prune, needs no clean git tree, and is safe to re-run (it asks before
overwriting a real config):

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

It creates or picks a project via the `firebase` CLI, writes the SDK config
into every file that carries it, and prints a deep-linked checklist of the
manual console steps (sign-in providers, Blaze plan, OAuth client). The
full walkthrough lives in the [Sign-in guide](/guides/auth/); headless flags
are in the [CLI reference](/reference/cli/).

## Headless mode

For CI or scripted setups:

```sh
pnpm create extstart --yes --scope minimal --name "My Ext"
pnpm create extstart --yes --name "My Ext" \
  --keep billing,gates,broadcasts --preset value-first
pnpm create extstart --dry-run --scope minimal   # print the plan only
```

`--yes` accepts flags/defaults with no prompts; without `--scope`/`--keep`
it keeps everything, and `--keep` wins when both are given. `--dry-run`
prints the plan and changes nothing. Full flag list:
[CLI reference](/reference/cli/).

## Rerunning

The wizard is built for one configuration pass on a fresh clone. Because it
requires a clean git tree, you can experiment safely: run it, inspect
`git diff`, and `git reset --hard` to try a different module combination.

By default the marker-cleanup step means a second pass can rebrand but no
longer prune; run the first pass with `--keep-markers` to keep that option
open ([semantics](/reference/cli/)). Adding a module back after committing
means restoring its files from git history, so keep what you're 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 real invariants: exact file paths, the policy guard, the definition of done. An agent that runs it lands green instead of rediscovering MV3 the hard way.

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

In Claude Code they're picked up automatically as 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 into chat, and replace `$ARGUMENTS` with your specifics.

## What else ships

- **`AGENTS.md`**: the single source of agent instructions: the architecture map, the verified MV3 pitfalls, the gate policy guard, and the security invariants. `CLAUDE.md` and `.cursor/rules/` are symlinks to it, so Claude Code, Cursor, and anything AGENTS.md-aware read the same file.
- **`llms.txt` + markdown mirror**: this docs 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 individual pages) for pasting the whole docs set into a context window.

## Debugging with Chrome DevTools MCP

The repo ships a project-scope MCP 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. The agent can *see* the failure instead of guessing: read service-worker console output and manifest errors, screenshot the popup or welcome page, watch the background's network calls, and click through a gate wall to verify timing.

**Claude Code** detects `.mcp.json` at the repo root automatically; it asks for approval on first use, nothing else to configure. Or add it explicitly:

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

**Cursor:** add the same server in Cursor Settings → MCP, using the identical config shape:

```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 already loaded (usually what you want for extension debugging, paired with `pnpm dev`).

## Guardrails: 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 anyway.

The definition of done is the same for agents as for humans. From the repo root:

```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 strategies, email/password, anonymous-first linking, and the token rules that keep credentials out of your UI.
---

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 just reads who's signed in.

## Choose your Google sign-in path

Both paths are fully wired. You pick one with a single env var:

|  | Web auth flow | Offscreen popup |
| --- | --- | --- |
| **Browsers** | Chrome, Edge, and Firefox | Chrome and Edge only |
| **Setup** | Create a Google OAuth client (about 5 console minutes) | Deploy the bundled sign-in page to your 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 OAuth redirect URI embeds your extension ID, which changes if you load unpacked from a new path | One extra Hosting deploy; no Firefox |
| **Best for** | Shipping to real users, multi-browser products | Getting started fast, Chromium-only products |

We recommend the web auth flow for production (it covers Firefox and skips popup UX), but the choice is yours; many Chromium-only products ship the offscreen path permanently. Under the hood: web auth flow uses `chrome.identity.launchWebAuthFlow` with the implicit OAuth flow (plus a `getAuthToken` fast path on Chrome, never an OAuth client secret); the offscreen path opens your Firebase Hosting page (`VITE_FIREBASE_HOSTING_URL`) in an offscreen document and completes sign-in there.

Independent of that choice:

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

## Setting up your Firebase project

:::note[Before you deploy anything]
Install the Firebase CLI and sign in: `npm i -g firebase-tools`, then `firebase login`. Deploys (Functions, Hosting) also require your project on the **Blaze plan**; Cloud Functions v2 won't deploy on Spark. The free-tier quota covers development.
:::

### The automated path (recommended)

The setup wizard does the whole CLI-automatable half for you:

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

It creates (or picks) a Firebase project, creates a web app, fetches its SDK config, and writes it everywhere it lives: `apps/extension/utils/firebase.ts`, `backend/firebase-hosting/public/signInWithPopup.js`, both `.firebaserc` files, and `VITE_FIREBASE_HOSTING_URL` / `WXT_API_URL` in `apps/extension/.env`. It then prints a deep-linked checklist of the steps no CLI can do (enable the sign-in providers, upgrade to Blaze, create the OAuth client). Paste the client id when offered and it writes `WXT_GOOGLE_OAUTH_CLIENT_ID`. Safe to re-run; see the [CLI reference](/reference/cli/) for `--firebase-project` / `--firebase-create` (headless).

### The manual path (what the script automates)

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). The checked-in config is `PASTE_YOUR_…` placeholders; the extension boots without them but shows "Connect your Firebase project" in every surface until they're replaced.
3. **Authentication → Sign-in method**: enable **Google** and **Email/Password** (and **Anonymous** if you use anonymous-first). *(This step is manual even on the automated path; the wizard deep-links you to the right console screen.)*
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. Put the client ID in `.env`: `WXT_GOOGLE_OAUTH_CLIENT_ID=<client-id>.apps.googleusercontent.com`.

:::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, re-check that the URI matches the *current* ID.
:::

### The offscreen popup path

If you chose the offscreen path (`WXT_GOOGLE_OAUTH_CLIENT_ID` left empty), the background opens an offscreen document that loads the page at `VITE_FIREBASE_HOSTING_URL` and completes sign-in there. That page lives at `backend/firebase-hosting/public/signInWithPopup.js` and needs your config too:

1. Paste your Firebase web config into `backend/firebase-hosting/public/signInWithPopup.js` (the `TODO` marker; same config as step 2 above). `pnpm create extstart --firebase` writes it for you.
2. Deploy it to your project's Hosting, 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, which is why the page can't just be opened locally.

**Failure symptom if you skip this:** clicking "Sign in with Google" opens a popup that closes again silently and you stay signed out. The iframe is still pointing at a page with placeholder config (or a Firebase project that isn't yours), 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, usage counters, and even purchases attribute to that uid from minute one, with no forced sign-up.
- Interactive sign-in **upgrades in place**: `linkWithCredential` keeps the uid, so entitlements (`customers/{uid}`) and counters (`usage/{uid}`) survive untouched.
- **Conflicts**: if the Google credential or email already belongs to an account, linking fails and the strategy signs into the existing account instead. The guest session's server-side data stays behind under the old uid; accounts are never merged silently. Email sign-up surfaces "email in use" and the UI steers to sign-in.
- Signing out returns to a *fresh* guest session.
- Anonymous users see the sign-in surface and count as signed **out** for gate identity. Converting them is the sign-in wall's job.

## The token rules

These are enforced by lint and architecture, not convention:

1. **ID tokens never leave the background.** UI and content scripts have no `getIdToken`; backend calls go through background messages (`billingCheckout`, `gateFeature`, …) and the background attaches the token.
2. **Content scripts get proxied state only**: `storage.local` snapshots (`user`, `entitlements`) 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, never Firebase directly, 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, so sessions on other devices end when their current ID tokens expire (≤1 hour). It's best-effort: local sign-out proceeds even if the network call fails.
5. Ephemeral/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: How the background works
description: "Service-worker-safe messaging, state, and timers: one idiom per problem, and when (not) to reach for each."
---

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
exactly **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`; proxy services for one-off calls |
| read-write app state shared across surfaces | **`defineStore`** (storage-backed, hydration-gated); `settings` is the example | module globals as truth; hand-rolled `storage.onChanged` wiring |
| background-single-writer read-only state (`user`, `entitlements`, `gateDecision`, `broadcasts`, `logs`) | **`defineStorageView`**: subscribe to the storage key from the UI; `useAuth` is the example | UI writing those keys; reading Firebase/APIs from a surface |
| a cohesive multi-method service | **`defineProxyService`**, and *only* then | wrapping single functions in a service; it's extra indirection for no gain |
| 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 scattered through readers |

### Typed messages – the default for UI → background

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

```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, gate checks all work this way. Never call raw
`runtime.sendMessage`.

### `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 just a
rehydratable view. Works in every context; 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/token-ish
data. Mind the `storage.sync` quotas if you use that area: 100 KB total,
8 KB per item, 512 items, 120 writes/min.

### `defineStorageView` – background-single-writer state, read from the UI

The kit's other state category is written by **exactly one place in the
background** and only read everywhere else: `user` (auth watcher),
`entitlements` (billing watcher), `gateDecision` (gate evaluator),
`broadcasts`, `logs`. Surfaces never write these and never talk to
Firebase/APIs for them. They 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, so the React layer is one line;
`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` (core-billing) and `useGateDecision` (gate)
are the same idiom under the hood. If you add background-owned state, this
is how the UI should read it.

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

The UI calls background functions as if they were local:

```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** when you have 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; typed messages cover everything it
does.

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

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

`setTimeout`/`setInterval` don't survive worker restarts, and keepalive
intervals are a Chrome policy violation; 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; there is no "fresh
install" reset for existing users. 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 sequentially before anything hydrates.

## The two rules that break everything when violated

1. **All listeners register synchronously at the top level.** An event can
   only revive the worker if its listener was registered during the first
   synchronous evaluation of the script. Anything registered inside an
   awaited init, a `.then`, or a `setTimeout` silently misses the very
   events that woke the worker. Top-level `await` is disabled for the same
   reason; await hydration *inside* handlers (a store's `ready`). The
   kit's helpers warn loudly if you register late.

2. **A new `chrome.*` API needs its manifest permission BEFORE the code
   lands.** A missing permission makes the 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's `module.json`) in the same
   change. The e2e suite loads the built manifest and 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 own feature
as a new module in that list; keep its listeners top-level and its state in
a store (or, if only the background writes it, expose it to the UI with a
storage view).

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

---
title: Credits
description: Enable the hybrid credits model, seed the metered prices, meter your features with creditsConsume, and test the flow.
---

Sell a subscription with a monthly credit allowance plus purchasable top-up packs: the model AI extensions like Monica and Sider run on. This page turns it on, 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 [one-time 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. At checkout the server copies them into session/subscription 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`. The gate engine raises the top-up wall when the balance reaches 0 and clears it when a pack purchase or the monthly reset raises it again. Accounts without credits (no metered plan, no packs) get `ok: true`; instrumented features simply 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 + 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 portion first. Packs roll over forever; unused allowance doesn't (it's replaced, not stacked, on each `invoice.paid`). Cancelling the subscription drops the remaining allowance but keeps pack credits.

Offline consumes are denied, not queued. A metered feature needs the backend to do its work anyway, and an offline queue would be a client-side free-usage lever.

Grants are webhook-written only, and `POST /credits/consume` can only ever *lower* a balance. Each consume writes a deterministic ledger entry (`customers/{uid}/credit_ledger/consume_<idempotencyKey>`), so a retried request replays its recorded outcome instead of double-spending.

:::note[Why the balance lives in your Firestore, not Stripe]
Stripe's own credit primitives (Billing Meters, Credit Grants) are invoicing-oriented and eventually consistent, unusable for a synchronous "can this feature run right now" check. So the real balance is `customers/{uid}`: `creditsRemaining` (total spendable, mirrored to the extension), `creditsAllowance` (the monthly grant), and server-only `packCreditsRemaining`. Stripe stays the payment rail.
:::

## Test it

- Firestore → `customers/{uid}`: `creditsRemaining`, `creditsAllowance`, `packCreditsRemaining`, 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 }`, the read path for tooling outside the extension.
- `pnpm doctor` verifies the pack/metered lookup keys referenced by your `site.config.ts` exist with their credit metadata, and that the webhook is subscribed to `invoice.paid`.
- `STRIPE_SECRET_KEY=sk_test_… pnpm test:lifecycle` includes a real test-clock scenario: the first invoice grants the allowance; a simulated month later the renewal resets it, packs surviving.

Price the model honestly. "Unlimited" plans with hidden fair-use caps are the most common credibility sinkhole in AI-extension reviews: show the CreditMeter, price the allowance for real usage, and let heavy users buy packs. Never gate the balance UI itself.

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

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

Set up Stripe end to end: products, webhook, one deploy, and UI that knows who paid. How money flows through the kit:

```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 Stripe webhook. Trials are `trialUsed`-enforced server-side, and prices resolve by `lookup_key`. The client never sends a price ID or amount.

## One-time setup (test mode)

You'll need the Firebase CLI and your project on the Blaze plan; see [setting up your Firebase project](/guides/auth/#setting-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, reading the key from Secret Manager.

```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 template products/prices by lookup_key (idempotent)
pnpm stripe:webhook     # registers the webhook endpoint + 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
```

The order matters. `stripe:webhook` computes the function URL deterministically from your project id (`https://us-central1-<project>.cloudfunctions.net/api`), so the webhook and its signing secret exist before the first deploy. That one deploy binds both secrets. Deploying earlier makes the Firebase CLI prompt for the missing secret.

What each step does:

- **`seed:stripe`** creates the template catalog by lookup key: `premium_monthly`, `premium_yearly`, `premium_lifetime`, `premium_metered_monthly`, and the top-up packs `credits_pack_small` / `credits_pack_large`. Change amounts freely in the Stripe dashboard; the backend resolves prices by `lookup_key` only. The keys must match `apps/extension/site.config.ts` → `pricing.plans`; unused products are harmless.
- **`stripe:webhook`** registers the endpoint at `…/api/billing/webhook` with the events the backend handles (`checkout.session.completed`, `customer.subscription.created/updated/deleted`, `invoice.paid`, `charge.refunded`) and stores the signing secret. Rerunning is a safe no-op.
- **`doctor`** checks project, secrets, deployed API, webhook registration, and seeded prices, and points at the fix for anything red. Run it after setup or whenever billing misbehaves.

Non-secret knobs are plain env on the function, read from `backend/functions/.env` (gitignored; that's why you copy `.env.example` first): `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 extension's CTA advertises one number, Stripe enforces the other, and `pnpm doctor` flags a mismatch.

If you dropped the billing module in the wizard, set `BILLING_DISABLED=true` in `backend/functions/.env` instead of any of the above: the deploy skips the Stripe secret bindings entirely and the `/billing/*` routes answer `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

The standard check, available in every React surface:

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

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

Also available: `useCredits()` (balance + `allowance` + `exhausted`, all `null` when credits aren't in use) and `useBillingState()` (the states-matrix key: `free/trial/active/past_due/cancel_pending/lifetime`). All are `useSyncExternalStore` views of the background-written storage snapshot. No component ever talks to Firestore or the billing API directly.

The credit-allowance model with top-up packs has its own page: [credits](/guides/billing-credits/).

:::danger[Never add a client-trusted purchasable check]
`useEntitlement` renders UI. The *feature itself* is protected by the server: Firestore rules deny all client writes to `customers/{uid}`, so an attacker who edits extension code can change what their copy displays, never what they're 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`, written only by the server.
- `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 plus the backend's JSON response: `{"outcome":"applied","uids":[…]}`, `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: trial → active →
                       # cancel → canceled (~3 min)
```

The billing core is port-based: an alternative provider (Polar, Paddle, Chargebee) 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: "Announce to every install in seconds: incidents, launches, and update notes without a store review."
---

Store review takes days; an announcement takes seconds: one Firestore
document renders as a banner in every open surface (the kit calls these
*broadcasts*). This is remote *data*, which CWS
sanctions; [remote *code* never is](/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. For "update available" nudges,
announce broadly and gate the feature in code; `minVersion` targets *newer*
versions, not older ranges.

## 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>` in the popup, sidepanel, and
options page shows the first active one. Dismissals are per-message and
local (`storage.local.broadcastDismissals`). Offline, the last mirrored list
keeps serving. The extension never writes to the collection; the rules say
`write: if false` for all clients.

## 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 to `storage.local.updateNotice` 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's always dismissible (a CWS policy point) and
shows once per version; the stored ID doubles as the "seen version" record.
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, plus 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; broad patterns increase review time.
- **Programmatic + `activeTab`** when the feature is user-invoked (toolbar click): access per click, no install-time warning.
- **MAIN world only 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 `<html>` font-size, so a `html { font-size: 32px }` page would double everything. The kit converts rem→px at build (PostCSS in `wxt.config.ts`) and the wrapper pins `font-size: 16px`.
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 must never decide your theme) and exposes a `useShadowContainer()` portal target. Never portal overlays to `document.body`; they'd 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 */
});
```

Remount work belongs in this handler, not in URL polling.

## 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/marks via `ignore` so your DOM writes don't re-trigger you (the infinite-loop guard).
- **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 is an end-to-end proof of everything above, running on hostile pages. Trace it in `apps/extension/components/content/Highlighter.tsx` and `apps/extension/utils/highlights.ts`:

1. **Select text** on any matching page → a shadow-UI button appears. It's mounted with `mountShadowUi`, so a page with `html { font-size: 32px }` and `* { all: revert }` can't distort it; the e2e suite asserts exactly 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** (`gateAction: highlight`). After enough actions, the paywall raises *on the page itself* for free users.
4. **SPA navigation and DOM mutations** re-anchor highlights via an idempotent `applyAll()` driven by `wxt:locationchange` and `observeDom`, with no duplicates.
5. **Click a highlight** to remove it for good.

When building 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 on the right side of Chrome Web Store policy."
---

This page controls when a sign-in wall or paywall appears. Pick a preset, 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) | AI/usage products with [credit billing](/guides/billing-credits/) |
| `silent` | only when you call `open("paywall")` | maximum review safety |

The wizard writes your chosen preset into `background/gates.ts` (`--billing-model hybrid-credits`/`credits-only` default it to `metered`); 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 surface the paywall immediately), `paywallAfterActions` (default 10), `paywallAfterDays` (default 3), `cooldownMinutes` (quiet period after a dismissal, default 24 h), and `escalateAfterDismissals` (dismissals before the wall turns non-dismissible; default 0 = never, and leave it that way for core features).

:::note[Why these defaults]
Forced account creation before value loses 18–26% of users (Baymard's checkout research, the famous "$300M button"), so no preset ever fires the sign-in wall on its own; it appears only chained, when an action needs an account. Paywalls are the opposite case: across 115k apps (RevenueCat), about half of all paid conversions happen on day 0 and almost none after day 3. `value-first` encodes both: sign-in late, paywall visible early but dismissible.
:::

## Gate a feature

Two message types instrument your product; both return `null` for "proceed" or a decision meaning "the wall is up; stop the action":

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

// A premium feature moment: call before running the feature:
const decision = await sendMessage("gateFeature", { feature: "export-pdf" });
if (decision) return; // wall is already rendering everywhere; just stop

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

Then add the feature ID to the preset's `premiumFeatures` in `background/gates.ts`. That's the whole integration: the engine handles the wall UI, cooldowns, and the sign-in chain. (Under the hood: the background evaluates once, publishes the decision to `storage.local.gateDecision`, and `useGateDecision()` + `<GateWall>` render it everywhere.)

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

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

The `metered` preset's `creditsExhausted()` trigger reads the real balance: the background mirrors `creditsRemaining` into the gate engine on every change. The wall fires the instant a consume hits 0 and clears when a pack purchase or allowance reset raises it. A *paid* metered subscriber with an exhausted balance doesn't count as satisfied for paywall walls; that's what lets the top-up wall reach them. Enforcement stays server-side; the wall is conversion UX. See [the credits model](/guides/billing-credits/).

Manual control (e.g. with the `silent` preset) uses `sendMessage("gateOpen", { gateId: "paywall" })`. The dev tools in the Settings tab (dev builds) reset all local gate state.

## "Why did this wall appear?"

Everything is answerable from **one file**: `apps/extension/entrypoints/background/gates.ts`. Reading it top to bottom:

1. `PREMIUM_FEATURES = ["premium-demo"]`: any `gateFeature` call with one of these IDs raises the paywall immediately.
2. The `valueFirst({ ... })` call spells out every timing parameter inline: `paywallAfterActions: 10`, `paywallAfterDays: 3`, `cooldownMinutes: 60 * 24`. It expands to exactly two gate definitions:
   - **signin**: trigger `manual()`; it can *never* fire on its own.
   - **paywall**: trigger `any(featureAccess(...), actionCount(10), daysSinceInstall(3))`, with `requires: "signin"` and `dismissible: true`.
3. So a wall appeared because (a) a listed premium feature was requested, (b) the 10th `gateAction` was recorded, or (c) it's been 3 days since install. If the user was signed out, the **sign-in wall renders first**, because the paywall `requires: "signin"`.
4. A wall *didn't* appear because the user is paid, the gate is inside its 24-hour post-dismissal cooldown, or the identity check (`getIdentity()` in the same file) says the requirement is already satisfied. The anonymous-first rule is visible right there: anonymous guests count as signed **out**.

Every number that controls wall timing is in this file's `valueFirst({ ... })` call; change it there, nowhere else. If a wall surprises you, this file is the complete explanation.

## Stay inside Chrome Web Store policy

CWS's **single-purpose policy** expects the functionality in your listing to work when the user installs. Review accounts are fresh installs; a wall they can't get past reads as bait-and-switch, a rejection/suspension class. The kit's guardrails:

1. **Core value stays usable pre-wall.** Gate premium *extras* via `premiumFeatures`; never put the action from your store listing's first sentence behind a paywall trigger. If everything in your product is paid, say so in the listing ("subscription required"): that's allowed; hiding it is not.
2. **Walls are dismissible by default.** Every preset ships `dismissible: true`; `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; `day-zero` is dismissible by design. Don't hand-write an install-time hard wall.
4. **Sign-in is never forced standalone**; chained only.
5. **For a review-sensitive product**, use `silent`: nothing fires automatically; you invoke walls from your own UX.

## The server-side mirror

Walls are conversion UX; the features they gate are enforced by entitlements and credits on the server. Every recorded gate event is also queued and flushed (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. Trial-once is enforced from the entitlement doc: once a trial has started, later checkouts omit the trial automatically. Anonymous usage stays local until sign-in.

## Remote config

`GET /gateConfig` serves flags/values/experiments as remote *data*: sanctioned, unlike remote code ([the rejection class](/publishing/rejection-codes/#purple-potassium--undisclosed-remote-code)). Wiring those values into gate thresholds is left to you via a config transform before `defineGates`.

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

---
title: The 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 feature
module carries a **`module.json` manifest** declaring everything it owns
(code, npm dependencies, env vars, manifest permissions, and 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
(broadcasts, error-reporting, content-demo, demo-newtab, demo-devtools),
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 (the auth module owns `apps/extension/entrypoints/offscreen/**`).
- `dependsOn` is by module ID. The resolver keeps dependencies of any kept
  module (keeping `gate` force-keeps `billing`) and drops dependents of any
  dropped one (dropping `billing` drops `gate` too).
- Core modules set `"removable": false`; the wizard never offers to prune
  them.
- **Shared last-owner rule**: env vars and permissions listed by several
  modules (e.g. `WXT_API_URL`) are pruned only when *no kept module* lists
  them.
- `permissions` is why the generated `manifest.json` shrinks when you prune:
  each module declares the `chrome.*` permissions and host patterns it
  needs, and a smaller permission surface means a faster, safer 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, e2e specs. Those
touchpoints carry **marker comments** so the pruner can strip them without
codemods:

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

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

## Backend files stay

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

## Adding 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. Where your module touches shared files (adding an import to
   `background/index.ts`, a message to the protocol, a component to a
   surface), tag each touchpoint with a `// module:<id>` line marker or a
   `module:<id>:start` / `module:<id>:end` block, and list those files
   under `wiring`.
3. Declare `npmDependencies`, `env` (names must exist in
   `apps/extension/.env.example`), `permissions`, and `dependsOn` as they
   apply.
4. **Keep the manifest in sync**: adding a file, dependency, env var, or
   permission to the module means updating its `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 (named colors, sizes, and radii) covers every
surface: popup, sidepanel, options, 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 (`--color-*: initial`), 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 |

To rebrand, replace the eleven `--color-accent-*` oklch values in the
`@theme` block with your brand's scale (Tailwind v4's palette reference is
a good source of 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.

The pairing convention for tinted chips and banners:
`{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** (`@custom-variant dark`), driven by the
settings store: `auto` follows the OS; light/dark override it. Style both
themes at authoring time, `dark:` variants throughout, and check every new
component in both themes before shipping.

Content-script shadow UIs get the theme class **on their shadow wrapper**,
never from the host page: the host page's classes must not decide your
theme (`mountShadowUi` handles this).

## Typography

- **InterVariable**, bundled locally in `assets/fonts/`; no CDN fonts, per
  the remote-code hygiene rule. Weights 100–900 in one variable file.
- 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/meta. Popup surfaces are dense; avoid anything above
  `text-lg` outside the welcome/options pages.

## Radius – memorize this one

| 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**, with a hard cap at 300 ms
and no spring or bounce curves. 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 & 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); the options content column is `max-w-xl`.

## Components

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

Two practical notes:

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

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

---
title: ExtensionStart Docs
description: "Everything you need to go from purchase to a published browser extension: setup, payments, paywalls, and store runbooks."
template: splash
hero:
  title: ExtensionStart Docs
  tagline: From purchase to a published extension. Sign-in, payments, paywalls, and store runbooks, documented end to end.
  actions:
    - text: Start here
      link: /getting-started/what-you-get/
      icon: right-arrow
      variant: primary
    - text: extensionstart.com
      link: https://extensionstart.com
      icon: external
      variant: minimal
---

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

<CardGrid>
  <LinkCard
    title="Start here"
    href="/getting-started/what-you-get/"
    description="Run one wizard, then follow the 5-step journey from clone to a live, monetized listing."
  />
  <LinkCard
    title="Publish to the Chrome Web Store"
    href="/publishing/first-submission/"
    description="The submission runbook: developer account, privacy tab, permissions, review expectations."
  />
</CardGrid>

## Download docs for your AI agent

If you're working with Claude, Cursor, or another coding agent, give it
the whole documentation set as context:

- [extensionstart-docs.md](/downloads/extensionstart-docs.md): every page in
  one concatenated Markdown file
- [extensionstart-docs.zip](/downloads/extensionstart-docs.zip): the
  individual Markdown files, zipped
- [llms.txt](/llms.txt) · [llms-full.txt](/llms-full.txt) ·
  [llms-small.txt](/llms-small.txt): machine-readable indexes following the
  llms.txt convention

Every page is also available as raw Markdown by appending `.md` to its URL
(for example [/guides/auth.md](/guides/auth.md)), or via the "Copy as
Markdown" button next to each page title.

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

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

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** (no $5 equivalent).

## 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 + privacy
fields (the same disclosure content as
[CWS](/publishing/privacy-disclosures/) applies) → submit. Reviews land
within days; as with CWS, plan for up to a week.

CI flow: the kit's `submit` script
(`pnpm submit`) targets Edge when
`EDGE_PRODUCT_ID`, `EDGE_CLIENT_ID`, and `EDGE_API_KEY` are present in the
environment (it wraps `publish-browser-extension`; `submit:dry` validates
credentials without uploading). The product ID comes from the Partner
Center listing URL after you've created the listing once by hand.

## The 72-day API-key expiry

The gotcha that silently breaks release pipelines: **Edge Add-ons API keys
expire 72 days after creation**. Symptoms: the Chrome and Firefox targets
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 also 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 in `.output/`.
- Edge users can also install straight from the Chrome Web Store, but a
  native Edge listing installs without the "allow extensions from other
  stores" friction and gets you Edge's own discovery surface.
- Staged rollout: Partner Center supports gradual rollout percentages on
  updates *(verify current availability for your account tier in the
  Partner Center docs)*.

<!-- ============================================= -->
<!-- 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 (with the Chromium-only permissions and APIs
filtered out) from the same codebase.

## 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 signing/submitting, set your own add-on ID in
`wxt.config.ts`; the Firefox manifest branch carries
`browser_specific_settings.gecko.id` with a placeholder
(`extension@extensionstart.com`) and a `TODO` to replace it.

What's 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 **the offscreen auth fallback is
  unavailable**: Google sign-in on Firefox requires
  `WXT_GOOGLE_OAUTH_CLIENT_ID` (the web-auth-flow path; see the
  [sign-in guide](/guides/auth/)).
- Chrome-only APIs are guarded in code (`browser.sidePanel?.…`).

## The source-code requirement

AMO reviews are human and stricter than CWS about build pipelines. Because
the kit's code is bundled and minified (Vite/WXT), you must upload the
**source code** alongside the extension zip, plus instructions that let a
reviewer reproduce the build byte-for-byte.

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

## Reviewer notes template

Paste into the "Notes for Reviewers" field 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 login via the
kit's email auth is the easiest to hand a reviewer).

## AMO-specific gotchas

- **Versions are immutable**: you can't replace an uploaded version. Fix
  and bump.
- The kit's CI already runs AMO's linter (`addons-linter`) against the
  Firefox build, so lint-class surprises should be caught before you
  submit.
- Data collection requires the same disclosure discipline as CWS; 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 are auto-published 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.
---

Step 5 of the [getting-started journey](/getting-started/what-you-get/):
the runbook for your first Chrome Web Store (CWS) submission with this kit.
Work top to bottom; nothing here assumes a previous publication.

## 1. 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** for the account. A phished
   publisher account, not code, is how the Cyberhaven compromise
   (Dec 2024) pushed a malicious update to 400k users. Longer term, use a
   group publisher with minimal members and publish from CI with scoped
   API credentials, never from laptops.

## 2. Build the zip

```sh
pnpm zip
```

`wxt zip` builds production and produces the store-ready zip in
`apps/extension/.output/` (the filename embeds the manifest version, e.g.
`…-1.0.0-chrome.zip`). Before uploading, run the kit's own gate:

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

## 3. Minimize permissions first

Every permission adds install-warning friction and review time. WXT
generates a minimal manifest and pruned modules take their permissions
with them, but review two items yourself before submitting:

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

| permission | why it's there | drop it when |
| --- | --- | --- |
| `storage` | every store/state primitive | never (core) |
| `identity` | Google sign-in (`launchWebAuthFlow` + `getAuthToken` fast path) | you remove Google sign-in entirely |
| `offscreen` | offscreen `signInWithPopup` fallback | you set `WXT_GOOGLE_OAUTH_CLIENT_ID` and delete the fallback |
| `alarms` | service-worker-safe timers (gate event flush, error flush) | you remove the gate module and use no alarms |
| `sidePanel` | the sidepanel surface | you remove the sidepanel entrypoint |
| `tabs` | reading `changeInfo.url` in 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 policy expects one narrow, clear purpose, and the listed
functionality must work on a fresh install; the
[paywalls guide](/guides/gates/) covers how the kit guarantees that.

## 5. The privacy practices tab

This tab is mandatory and a common cause of "can't submit" confusion;
every field must be filled:

1. **Single purpose**: one sentence describing what the extension does.
2. **Permission justifications**: one per permission; use the table above.
3. **Data usage**: what user data you collect, mapped to CWS's categories.
   Paste the kit's exact answers from
   [Privacy disclosures](/publishing/privacy-disclosures/).
4. **Certifications**: the three compliance checkboxes;
   [Privacy disclosures](/publishing/privacy-disclosures/#the-certification-checkboxes)
   walks through 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 configured in
   `site.config.ts → urls.privacy`.

## 6. Submit, and what to expect

- Typical review time is **hours to a few days**. Broad host permissions
  and newer developer accounts routinely stretch that, up to a few weeks
  in the worst cases. Don't plan a launch date on review completing
  overnight.
- You can choose **deferred publish**: get approved first, press publish
  when you're ready.
- **Staged rollout** publishes to a percentage of users. The dashboard
  offers it only for items with a large existing install base, so it won't
  apply to submission #1. Verify the current eligibility threshold against
  the CWS docs.
- If you're rejected, the email names a rejection reason. Look it up in
  [Rejection codes](/publishing/rejection-codes/) for what triggered it
  and how to recover.

## 7. After approval

- Monitor the listing for unexpected versions (supply-chain hygiene).
- 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).
- Broadcasts ([guide](/guides/broadcasts/)) let you talk to installs while
  a review is pending; announcements are remote *data*, which is
  [allowed](/publishing/rejection-codes/#purple-potassium--undisclosed-remote-code).

<!-- ============================================= -->
<!-- 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-practices tab asks what user data your
extension collects, category by category. This page maps the kit's
**actual** collection footprint to those categories, with template answers
you can paste.

:::danger[These answers describe the unmodified kit]
Every answer below is true for the kit as shipped. **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 (see
[rejection codes](/publishing/rejection-codes/#purple-lithium--purple-nickel--data-use-disclosures)).
Re-audit this page before every submission.
:::

## What the kit actually collects

| data | where it goes | notes |
| --- | --- | --- |
| Auth profile (email, name, avatar, uid) | Firebase Authentication | only when the user signs in; anonymous-first mode creates an account without personal info |
| Billing state (plan, subscription status, Stripe customer ID) | Stripe + Firestore `customers/{uid}` | only when the user purchases; 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 name, extension version) | Firestore `errors` | **consent-gated** ("Share crash reports" toggle), keyed by a random install ID, never a uid; content scripts report only errors whose stack points at the kit's own bundle |

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 explicitly copies them).

## The questionnaire, category by category

CWS asks "Which of the following types of user data do you collect?".
The answers for the unmodified kit:

| CWS category | collect? | why |
| --- | --- | --- |
| Personally identifiable information | **Yes** | email address and name via sign-in (Firebase Auth) |
| Health information | No | n/a |
| Financial and payment information | **No** | purchases happen on Stripe-hosted Checkout pages; the extension never sees payment details. Disclose subscription *status* under PII/authentication if in doubt |
| Authentication information | **Yes** | Firebase auth credentials/tokens (background-only; never synced or 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 ever collected |
| User activity | **Yes, minimal** | in-extension feature-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 in the table above.
- [ ] "Share crash reports" default matches your policy copy (the kit
      ships it as a consent toggle in Settings; 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 corresponding disclosures: no
      `billing` → no Stripe/billing rows; no `error-reporting` → no crash
      report rows; no auth-requiring features in use → reconsider the PII
      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 this 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're most
likely to meet to what actually triggered them, how the kit prevents them
by construction, and what to do if one lands in your inbox anyway.

:::caution[Verify before you rely on a code name]
Google occasionally renames, splits, or retires these codes, and the
official list lives in the CWS "Troubleshooting Chrome Web Store violations"
documentation. The trigger/prevention/recovery guidance below is stable;
**treat the code names as best-effort and check the current CWS docs when
reading a rejection email.**
:::

## Code-quality and remote-code classes

### Blue Argon – obfuscated or unreadable 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. If you must protect logic, move it server-side.

### Purple Potassium – undisclosed remote code

- **Triggers**: fetching and executing JS/WASM at runtime, such as remote
  `<script src>`, `eval` of downloaded strings, and script-injecting
  analytics SDKs.
- **Kit prevention**: this is the class the kit is most opinionated about.
  Lint bans `eval`, `new Function`, implied eval, and innerHTML sinks.
  `pnpm audit:remote-code` scans the **built** output (every JS/HTML file
  plus the manifest CSP) for `eval`, `new Function`, `importScripts()`,
  `document.write`, string `setTimeout`, remote script tags, and
  `unsafe-eval`/remote-host CSP. It runs in CI on every build, 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.

## Metadata classes

### Red Nickel / Red Titanium – metadata quality

- **Triggers**: listing problems such as keyword-stuffed or misleading
  title/description, irrelevant screenshots, duplicate listings, and
  unverified claims.
- **Kit prevention**: nothing technical can prevent listing copy issues.
  Write the description around your single purpose, keep screenshots
  current, and don't enumerate competitor names.
- **Recovery**: rewrite the listing fields named in the email and resubmit;
  these are usually fast re-reviews.

## Functionality classes

### 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, dead UI). A paywall the reviewer can't get past lands here too
  (bait-and-switch reads).
- **Kit prevention**: the e2e suite runs 12 Playwright specs 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 loaded unpacked. If your product genuinely requires an account or
  purchase, say so in the listing and provide **test credentials in the
  review notes**.

## Permission classes

### Blue Lithium – unjustified permissions

- **Triggers**: requesting 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; the manifest ships with the minimum for enabled features
  and every entry has a written rationale
  ([first submission §3](/publishing/first-submission/#3-minimize-permissions-first)).
  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.

## Data-disclosure classes

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

- **Triggers**: collecting user data without matching disclosures (a
  privacy-practices tab that doesn't mention data your code collects, a
  missing or dead privacy-policy URL, disclosures inconsistent with the
  Limited Use policy).
- **Kit prevention**: the kit's collection footprint is small and fully
  mapped; see the copy-paste answers per questionnaire category in
  [Privacy disclosures](/publishing/privacy-disclosures/). Error reporting
  is consent-gated and content-free by construction.
- **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 any of these

- Re-read the email carefully; it names the specific 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.
- You can **appeal** via the developer dashboard when you believe the
  rejection is a false positive; include precise 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 | Almost always the zip: upload the file produced by `pnpm zip` from `.output/`, not a hand-made archive. The `manifest.json` must sit at the zip **root**, not inside a folder. |
| "Invalid manifest" / manifest key warnings | Never hand-edit `manifest.json`; it's generated. Fix the source in `wxt.config.ts` and rebuild. Uploading a Firefox zip to CWS (or vice versa) also lands here: browser-specific keys like `browser_specific_settings` belong only in the Firefox build, which the kit's per-browser manifest function already handles. |
| "Cannot submit: privacy practices incomplete" | Every permission needs a justification and every data question an answer on the Privacy tab. 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` (the manifest function) 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 developer accounts, and first submissions all extend review.
  There's no expedite button; 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.

## The extension was approved but broken

- Test the **exact store zip**: unzip the file you uploaded and load it
  unpacked in a fresh profile. `pnpm dev` output and the production zip
  differ (env, minification).
- Check env values baked into the build: `WXT_API_URL` and the Firebase
  config are compiled 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 a subset of 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

- Developer-account emails asking you to "verify your item" by granting
  OAuth access are **phishing**; the store never asks for that. It's the
  exact lure behind the
  [Cyberhaven compromise](/publishing/first-submission/#1-developer-account).
  Report and delete.
- Payments profile / identity-verification holds block publishing entirely
  until resolved in the dashboard; start that process before launch day,
  not on it.

<!-- ============================================= -->
<!-- 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--the-default-for-ui--background)).
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.
