# @monetize.software/sdk-extension

SDK for Chrome extensions. A single offscreen document holds the BillingClient,
AuthClient and EventTracker — the single source of truth for all tabs, popups,
side panels, and extension pages.

The content-script public API is **drop-in compatible** with `@monetize.software/sdk` —
the host writes `import { PaywallUI } from '@monetize.software/sdk-extension'` and
gets the same class with the same method set.

> ⚠️ **Bundle as an npm dependency. Do not load from a CDN.**
> Chrome Web Store [MV3 policy](https://developer.chrome.com/docs/webstore/program-policies/mv3-requirements) forbids remote code execution — every line of JS your extension runs must be reviewable at submission time and ship inside the extension package. `pnpm add @monetize.software/sdk-extension` and bundle it with Vite/Rollup/webpack like any other npm dep. Loading this package (or `@monetize.software/sdk`, or `@monetize.software/sdk-react`) from `esm.sh`/`unpkg`/`jsDelivr` from a content script, popup, or service worker will get the extension rejected by review, or removed retroactively if the policy violation is spotted later. This is also why we publish `sdk-extension` as a separate package — its content-script bundle has all dependencies inlined, no runtime fetch of code.

## Architecture

```
content script (per tab) ──port──▶ service worker ──port──▶ offscreen
        │                              (forwarder)              │
   Shadow DOM modal                                  BillingClient
   RemoteBillingClient                               AuthClient
                                                     EventTracker
                                                     UserWatcher
```

- **content-script:** UI + RemoteBillingClient (proxy over a port into offscreen).
- **service worker:** content↔offscreen router. OAuth uses a popup window opened
  against your `apiOrigin` (custom_domain) — `chrome.identity` is **not** used.
- **offscreen:** the real SDK state, survives tab closes, the sole coordination
  point for auth refresh / trial counter / analytics batching.

## Usage

**In the extension:**

```ts
// service-worker.ts
import { installRouter } from '@monetize.software/sdk-extension/sw';
installRouter({
  offscreenUrl: chrome.runtime.getURL('offscreen.html'),
  apiOrigin: 'https://your-paywall-domain.com'   // see "Surviving surfaces" below
});
```

```ts
// offscreen.html → offscreen.ts
import { startOffscreenServer } from '@monetize.software/sdk-extension/offscreen';
startOffscreenServer({ paywallId: '...', apiOrigin: 'https://...' });
```

```ts
// content-script.ts (in every tab)
import { PaywallUI } from '@monetize.software/sdk-extension';
const paywall = new PaywallUI({ paywallId: '...', apiOrigin: '...' });
paywall.open();  // exactly like @monetize.software/sdk
```

**On websites** — keep using `@monetize.software/sdk`, nothing changes.

## Advanced: calling the metered API gateway yourself

`PaywallUI` covers the paywall + checkout flow. If you also hit the metered AI proxy
(`/api/v1/api-gateway/...`) directly, build `ApiGatewayClient` from
`@monetize.software/sdk/core` and pass the extension's `RemoteAuthClient` as the auth
source — the Bearer is resolved in offscreen via `getAccessToken()`, so there is still a
single AuthClient and no token duplication:

```ts
// content-script.ts / popup.ts
import { ApiGatewayClient, QuotaExceededError } from '@monetize.software/sdk/core';
import type { AuthClient } from '@monetize.software/sdk/core';

const gateway = new ApiGatewayClient({
  paywallId,
  apiOrigin,                                    // same custom_domain as PaywallUI
  auth: paywall.auth as unknown as AuthClient,  // RemoteAuthClient, duck-typed
  onQuotaExceeded: () => paywall.open()         // 402 → show the paywall
});

try {
  const res = await gateway.call({ providerId, path: 'v1/chat/completions', body });
  // res is the raw Response — res.json() / res.body.getReader() for SSE.
} catch (err) {
  if (err instanceof QuotaExceededError) { /* out of quota */ }
}
```

> Import `ApiGatewayClient` **and** `QuotaExceededError` from the same
> `@monetize.software/sdk/core` entry. `sdk-extension` deliberately does **not** re-export
> them: its content bundle inlines a copy of `sdk`, so a `QuotaExceededError` re-exported
> from `sdk-extension` would be a *different* class identity and silently break `instanceof`
> at the call site. `ApiGatewayClient` itself is transport-agnostic infra and belongs in
> `core`, not in the chrome-remoting package.

## Manifest: what to declare in the host extension

The SDK itself does not add anything to the manifest — the host extension picks
permissions to match its own UX. Minimum for the SDK to work:

```json
{
  "permissions": ["offscreen", "storage"],
  "host_permissions": ["https://your-paywall-domain.com/*"],
  "background": { "service_worker": "sw.js", "type": "module" }
}
```

`host_permissions` must list **your `apiOrigin`** — the `custom_domain` configured
for your paywall in the platform (the same value you pass to `new PaywallUI({ apiOrigin })`).
This is the only origin the SDK calls from offscreen / SW / content-script (bootstrap,
checkout, billing, auth). There is no `api.monetize.software` — every customer ships
their own custom domain.

`web_accessible_resources` for `offscreen.html` is **not required** — the document
is created by the service worker via `chrome.offscreen.createDocument`, a Chrome API
that doesn't need WAR. Listing it adds attack surface (any site could `<iframe>` your
offscreen, plus it fingerprints your extension ID).

The SDK does **not** use `chrome.identity` — OAuth runs via a popup window opened
against your `apiOrigin`, so no `"identity"` permission is needed.

### Surviving surfaces: pass `apiOrigin` to `installRouter`

OAuth normally returns the auth code by `postMessage` to the window that started
it. A **toolbar action popup does not survive that**: Chrome closes it the moment
the provider window takes focus, and whether that happens is up to the OS window
manager — the same extension signs in fine on one machine and silently fails on
another (users see the popup vanish and nothing happen; opening DevTools "fixes"
it, because DevTools keeps the popup alive).

Passing `apiOrigin` to `installRouter` closes that hole. The service worker
watches for the provider's redirect landing on your callback page, reads the code
from the URL, and hands it to offscreen, which owns the PKCE verifier and
completes the sign-in on its own. The originating surface no longer needs to be
alive; every surface still open gets the usual `authChange`, and the next one to
open picks the session up from storage.

No new manifest permission is required — the URL is already visible to the worker
through the `host_permissions` entry you declare for `apiOrigin`. Omit the option
and behaviour is exactly as before.

**The purchase continues too.** When the sign-in was gating a checkout
(`checkout_mode: 'preauth'`), the intent travels with the flow: offscreen creates
the checkout as soon as the session lands, and the worker opens it as a tab in
the window the user is actually working in. They go provider → payment without
reopening an extension and clicking buy a second time. Nothing to configure —
`PaywallUI` passes the pending purchase automatically.

The payment page deliberately does **not** reuse the provider window: that window
closes itself moments after the redirect lands, and it is a 480×640 popup, which
is no place to enter card details. If the user turns out to already own the
subscription, no checkout is opened at all — the next surface they open shows the
restored state.

Two caveats worth knowing:

- If the **offscreen document itself** died mid-flow (extension reload/update,
  browser restart, OOM), the verifier is gone and nothing can be adopted — the
  user signs in again.
- Provider **errors** come back in the URL fragment, which a worker cannot read.
  Those still surface the old way: the window closes without a code and the flow
  reports a cancellation.

For a paywall that must work in a popup regardless, a **side panel** or a
full-page extension tab remains the sturdiest surface — neither is tied to focus.

### `host_permissions` — what to pick

`host_permissions` control two things: where the extension can `fetch` (from
offscreen / SW / content-script) and which origins the content-script can be
injected into (together with `content_scripts.matches`).

| Scenario | Recommendation |
|---|---|
| **Host extension already needs `<all_urls>`** (recorder, all-sites tool, assistant) | Keep `<all_urls>`. SDK works as-is. **Risk:** Chrome Web Store review for `<all_urls>` is a manual audit and takes longer; AV vendors (Avast/Kaspersky/etc.) are more likely to flag such extensions as PUA. That's the price of broad injection — it's a property of your use case, not an SDK risk. |
| **Host extension only talks to your backend and gates its own features** (popup tool, side-panel app) | Do NOT request `<all_urls>`. Your `apiOrigin` (custom_domain) is enough: `["https://your-paywall-domain.com/*"]`. No content-script injection on every site needed. |
| **Hybrid** — popup tool, but content-script needed on a narrow list of domains | Constrain both `host_permissions` and `content_scripts.matches` to those domains: `["https://*.your-target.com/*", "https://your-paywall-domain.com/*"]`. |

The main signal to CWS/AV: the narrower `host_permissions`, the less suspicion.
Keep `<all_urls>` only when it's genuinely required for your UX, and be ready to
justify it in CWS review (the "Permission justification" field).

## Demo extension: build modes

For self-testing and e2e there's `demo-extension/` — a reference implementation.
Two builds are available:

```bash
pnpm build:demo       # production build (= the template clients can copy)
pnpm build:demo:e2e   # debug build — exposes window.__paywall for Playwright
```

`build:demo` does NOT put `window.__paywall` into the bundle (dead-code-eliminated
via `import.meta.env.MODE !== 'e2e'`). The template clients copy stays clean: any
script on the page could otherwise call `paywall.open()` / `paywall.track()` and
abuse someone else's extension.

`pnpm dev:demo` builds in e2e mode (handy for live debugging from the DevTools console).
