# Connecting a Chrome extension

## The basic path, no backend

1. Create a project in Ext Ops Panel: the ID of a published extension, or a draft. For a draft, fill in the listing and run the audit; the ID appears after publication.
2. In Setup → Hosted pages, take the exact welcome and uninstall URLs of this project. Do not build a page code or a domain by guesswork.
3. In the MV3 service worker, open welcome only when `reason === 'install'`, then call `chrome.runtime.setUninstallURL(uninstallUrl)`. Check that the permissions and the background script in the manifest fit the existing extension. Keep the current install handler if there is one.
4. In the panel, set up the uninstall reasons, the win-back text and Rate Us. Add the privacy policy URL to the listing if needed.
5. Run the connection check and verify the events on a test install. Do not conclude the integration works just because the URLs are in the code.

Example code to adapt to the existing service worker:

```js
chrome.runtime.onInstalled.addListener(({ reason }) => {
  if (reason === 'install') chrome.tabs.create({ url: welcomeUrl });
});
chrome.runtime.setUninstallURL(uninstallUrl);
```

## Additional connections

- **Feedback on your own site:** the `https://extops.dev/widget.js` script and the public `data-extops-widget` key from the settings. For a form of your own, use the feedback OpenAPI. Do not send a real message just to test the UI.
  - **Signed-in users (optional):** once the owner turns on "Accept user data from the page" in the widget settings, the page can call `ExtOpsFeedback.identify({ user_id, email, name, meta, token })` after `widget.js` loads, and `ExtOpsFeedback.identify(null)` on sign-out; `open({ topic, message, contact, meta })` attaches request context (up to 10 keys `[a-z0-9_]{1,32}`, string or number values up to 200 characters, no secrets or card data). Without `token` the request arrives as "not verified".
  - **Verified identity:** the signature is computed only on the owner's server with the widget identity secret (`wis_…`, created in the widget settings): `hex(HMAC-SHA256(secret, "v1|" + widget_key + "|" + user_id + "|" + email + "|" + exp))`, `exp` in Unix seconds no later than 24 hours ahead, a missing field signed as an empty string. The page gets `{ exp, sig }` from that server endpoint and refreshes it before `exp`. Never put the secret into the page, extension code or tool output.
  - **Conversation in the widget (optional, paid):** the owner answers people from the panel, and a signed-in visitor reads the replies and writes back in the widget; no e-mail is ever sent. It works only when all of these hold: "Accept user data from the page" is on, the widget has an identity secret, the owner's plan includes the feedback CRM (Indie, Studio or Fleet) and the owner switched on "Conversation in the widget". Then `GET /api/v1/feedback/widget/config` carries `"replies": true` (otherwise the field is absent), and `widget.js` loads `widget-thread.js` from the same origin only when the current `identify()` has a `token`: a badge with unread replies, a Conversation view, `ExtOpsFeedback.open({ view: 'thread' })` (the usual form without the module). The host CSP needs nothing new. Reading needs a VALID signature, so for these widgets sign with `exp` about one hour ahead and renew by calling `identify()` again with a fresh token; an expired token hides the conversation until then.
  - **Own dialog or strict CSP:** `POST /api/v1/feedback/widget/thread` with `{ widget_key, user, identity, mark_seen?, summary? }` returns `{ messages: [{ id, from: "you" | "team", text, at }], unread }` (oldest first, at most 50; `summary: true` returns only `{ unread }`); `POST /api/v1/feedback/widget/thread/messages` with `{ widget_key, user, identity, message, event_id, dwell_ms }` writes a follow-up. `text` is plain text: render it only as text (`textContent`), never as HTML. A bad or expired signature returns 401 `identity_invalid`; every refusal before the signature check (conversation off, Free owner, wrong origin, unknown key) is the same 404. Check fields and limits against the feedback OpenAPI.
  - **People and replies in the panel (feedback CRM, paid):** widget messages that carry a `user_id` or `email` from `identify()` are grouped into people inside one project; the project's Feedback tab switches between Feed and People, and the web app has a Requests section with people from all the owner's projects (the extension links to it). A signed identity and the same id or e-mail without a valid signature are two separate people, marked "Verified" and "Not verified", never merged. Messages without `identify()` data stay only in the feed; the free-text contact field never creates a person. So if the owner wants people, pass `user_id` or `email` to `identify()`, and sign it when they need verified people or the conversation in the widget. In a person's timeline the owner sees messages and replies in time order, sets Open or Resolved (a new message reopens a resolved person), keeps a private note the person never sees, replies in plain text (up to 4000 characters; a reply is never edited, only deleted) or uses "Reply from mail" when there is an e-mail: it opens the owner's mail app with the message quoted and records that they answered; we never send e-mail. The list shows "Answered" when the last entry of the conversation is the owner's. Plan: the `FeedbackCRM` entitlement of the owner's account plan (Indie, Studio, Fleet); a trial does not unlock it. On Free the feed works as before, People shows a lock, and messages are still linked to people, so the history is complete after an upgrade. Retention: conversations are kept with no time limit on plans with the CRM; anonymous widget messages, the uninstall survey, Rate Us and everything on Free are kept 180 days; after a CRM plan ends, the 180-day rule starts 30 days later. Deleting a person ("Delete conversation and person data" in the person's menu) erases their messages, the owner's replies and the note on any plan; use it for a deletion request. Do not create test people or replies in a production project.
- **Custom metrics:** implement a GET endpoint on your service that returns a `schema_version: "2.0"` payload; configure the source and its authorization in the panel. Check `kinds`, `render` and required fields against the OpenAPI.
- **Extension monitoring:** set up a heartbeat or a URL ping in the project. The heartbeat token lives on the server or agent; POST to `/api/v1/monitoring/report` uses contract v2 with the required `overall` status.
- **Web project / environment monitoring:** a separate POST `/api/v1/heartbeat/report`. The minimal payload is `{ "overall": "ok" }`, valid statuses are `ok`, `warn`, `crit`; the rich v2 metrics payload is supported too. Use the environment token shown when the heartbeat is registered.

## Customer card endpoint

**What it is.** When the owner opens a verified person in People (project Feedback tab or Requests in the web app), our server sends a signed `POST` to an HTTPS endpoint on the owner's server and shows the answer next to the conversation: plan, subscription status, payments, product counters, links to the owner's admin. The owner's server answers, ours calls, as with custom metrics. We do not store the card (no database, cache or logs); every opening of a person is a new request. The panel never runs the card's actions. Contract: https://extops.dev/docs/customer-card, OpenAPI: https://extops.dev/customer-card-openapi.yaml (the endpoint is described under `webhooks`). In the Ext Ops Panel repository the canon is `docs/customer-card-contract.md`.

**Before you build it.**

- The card comes with the feedback CRM of the owner's ACCOUNT plan (Indie, Studio, Fleet); a trial does not unlock it. After a downgrade the settings stay, but no card is requested.
- **Verified people only.** The card is requested only for a person whose identity the owner's server signed (`identify()` with `token`, signature v1 with `wis_…`, see "Verified identity" above). For a person who is not verified there is no request at all, and the panel says "Card is available only for verified people": anyone can type an e-mail, and a stranger's data would end up next to their message. So set up signed identity first; without it the card never appears.
- We can switch card requests off for everyone: then no card is requested, the card and Test answer 404, and the owner's settings are kept. Do not try to work around it.

**Where it is configured.** Project → Setup → "Feedback widget" section → "Customer card" subsection (owner only). The Feedback tab only reads: a person without a card shows "Set up in project settings", a link to this subsection.

1. "Endpoint address": `https://…` on port 443 only, a public host (not `localhost`, `.local`, `.internal` or a private IP). Checked on save. No redirects are followed.
2. "Issue secret": `ccs_` + base64url of 32 random bytes. It is shown ONCE; afterwards the panel shows only its last 4 characters. The owner copies it into their server's configuration (an environment variable or a secret store, for example `EXTOPS_CARD_SECRET`).
3. "Replace secret": the new one is shown once, **the previous one keeps working for 24 hours**, and during that window every request carries two signatures. The endpoint needs only one secret at a time: deploy the new value any time within 24 hours.
4. "Test": sends a request with `test: true` and the identity typed into the form (empty fields send `test` / `test@example.com`). The panel shows "Card received, fields: N", a 404 as "customer not found, the signature check passed", or the HTTP status with up to 300 bytes of the answer. At most 10 tests an hour per project.
5. "Show the card": turns on only with a saved address and secret.

**The request our server sends.**

```http
POST https://support.example.com/extops/customer-card
Content-Type: application/json
Accept: application/json
User-Agent: ExtOpsPanel-CustomerCard/1.0 (+https://extops.dev/docs/customer-card)
X-ExtOps-Card-Timestamp: 1760371200
X-ExtOps-Card-Signature: v1=035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283
X-ExtOps-Request-Id: 6b0d1c9e-2f4a-4c55-9d0e-1f2a3b4c5d6e

{"schema_version":"1.0","project_id":"0c6a5c1e-6f1b-4b8e-9a52-3d1f0e7c2a10","person":{"user_id":"u_42","email":"anna@example.com","verified":true},"locale":"en","test":false}
```

- `project_id` is our project id (one endpoint may serve several projects). `person.user_id` (≤ 128) and `person.email` (≤ 254) are what the owner's server signed, either may be `null`; `person.verified` is always `true`. `locale` is the reader's panel language: `en`, `ru`, `de`, `es`, `fr`, `pt`. `test: true` is the Test button: a demo card is fine.
- The name, `meta`, message text and our internal person id are never sent. The method is always `POST`, so the e-mail and id stay out of the address bar and access logs.
- Timeout 3 seconds for the whole request (connect 2 s), no retries; a `3xx` counts as a failure. Answer from your own database, without slow calls to third parties.

**Signature v1.**

```
signed    = "v1|" + timestamp + "|" + METHOD + "|" + path_with_query + "|" + hex(SHA256(raw_body))
signature = hex(HMAC-SHA256(card_secret, signed))
header    = "v1=" + signature
```

- `timestamp` is the `X-ExtOps-Card-Timestamp` header (Unix seconds); `METHOD` is `POST`; `path_with_query` is the path of the configured address with its query, as in the HTTP request line (`/extops/customer-card`, `/hooks/card?project=a`; an empty path is `/`). If a reverse proxy rewrites the path, verify against the path the configured address has.
- `hex` is lowercase; `card_secret` is the whole `ccs_…` string as UTF-8 bytes; the hash is taken over the **raw body** before JSON parsing.
- After a rotation, for 24 hours the header carries two signatures separated by a comma: `v1=<new secret>,v1=<previous secret>`. Accept the request when at least one matches.
- The endpoint checks: both headers are present; `|now − timestamp| ≤ 300` seconds; at least one signature matches, compared in constant time. Otherwise answer `401`. Optional strict replay protection: remember `X-ExtOps-Request-Id` for 5 minutes.

Node (Express) check, the same text as the contract and the starter in the panel settings (Go and Python versions are on the contract page):

```js
// Node (Express): needs the raw body, for example express.raw({ type: 'application/json' })
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';

export function verifyCard(req, secret) {
  const ts = req.get('X-ExtOps-Card-Timestamp');
  const header = req.get('X-ExtOps-Card-Signature') || '';
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const bodyHash = createHash('sha256').update(req.body).digest('hex');
  const signed = `v1|${ts}|${req.method}|${req.originalUrl}|${bodyHash}`;
  const want = Buffer.from(createHmac('sha256', secret).update(signed).digest('hex'));
  return header.split(',').some((part) => {
    const got = Buffer.from(part.trim().replace(/^v1=/, ''));
    return got.length === want.length && timingSafeEqual(got, want);
  });
}
```

Test vector for a unit test (fix the clock, the timestamp is old): secret `ccs_test_vector_secret_not_for_production`, previous secret `ccs_previous_test_vector_secret`, timestamp `1760371200`, `POST /extops/customer-card`, the body above as one line with no trailing newline. `hex(SHA256(body))` = `dcd0d48f2fb5d26f835b39e51076c79db02fc2bc4b98a364ab5c506959f45146`, signature = `035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283`, with the previous secret = `1f1ecbc40946bd56eefa381a7c65e01c780a28c37f5113b345d500ed3b0b94cd`.

**Statuses.** `200` + JSON card: the card. `404`: no such customer in the owner's system, the panel shows "Customer not found in your system". Anything else (`401`, `5xx`, `3xx`, timeout, not JSON, limits broken): "Card unavailable", and the conversation keeps working. An error body is read up to 300 bytes and shown only to the owner through Test.

**The card schema** (`Content-Type: application/json`, UTF-8, body ≤ 64 KiB):

```text
Card    { schema_version: "1.0" (required, major 1), title?: ≤ 80, subtitle?: ≤ 160, status?: Status,
          sections: Section[] 1..8 (required), actions?: Action[] ≤ 6 }
Section { id: slug ≤ 40, title?: ≤ 60, fields: Field[] 1..20, actions?: Action[] ≤ 6 }
Field   { id: slug ≤ 40, label: ≤ 60, type, value, ref?: Ref, hint?: ≤ 160 }
  text    value: string ≤ 500
  money   value: { amount_minor: integer, currency: ISO 4217, three letters }
  date    value: "YYYY-MM-DD" | RFC 3339
  status  value: Status
  link    value: { url: https URL ≤ 2048, text?: ≤ 80 }
  count   value: integer ≥ 0
Status  { label: ≤ 40, tone: "ok" | "warn" | "bad" | "neutral" }
Ref     { kind: "payment" | "invoice" | "subscription" | "customer", id: ≤ 128, provider?: ≤ 32 }
Action  { type: "link", label: ≤ 40, url: https URL ≤ 2048 }
      | { type: "copy", label: ≤ 40, value: ≤ 500 }
      | { type: "prompt", label: ≤ 40, prompt: ≤ 4000 }
```

- In the whole card: at most 40 fields and 12 actions. A `slug` is `[a-z0-9_-]`, 1 to 40 characters; lengths count Unicode characters. `amount_minor` is in minor units (cents). `ref` is a machine hint the panel does not show yet; an unknown `kind` drops the `ref`, not the field.
- Our server parses the answer and the browser never gets the raw body. Unknown field or action type, or unknown `tone`: that element is dropped. A broken limit on an element: the element is dropped whole, not truncated (an optional string over its limit, such as `title`, `subtitle`, a section title or `hint`, is dropped alone). Too many sections, fields or actions: the extra ones from the end. A section with no valid field is dropped. No `schema_version` with major 1, no valid section, over 64 KiB or not JSON: the whole card is "unavailable". Links are `https:` only: a `link` field with another address shows as text, a `link` action is dropped. Unknown keys are ignored.

**Actions.** `link` opens an `https:` address in a new tab; `copy` copies `value`; `prompt` shows the text and copies it for the owner's own AI support agent. The panel never runs or sends anything. Never put the visitor's message text into a prompt (that is prompt injection into an agent that may have rights over money), and write into the prompt that a human must confirm any action with money.

**What not to put in a card.** Everything in the card reaches the owner's browser. Keep it to the minimum: plan, subscription status, payment amounts and dates, product counters, links to the owner's admin. Never: card data (numbers, CVC), passwords, tokens, API keys, postal addresses, phone numbers, data about other people. The owner already has the e-mail and name from the request. The endpoint should read from the owner's own database; do not give it a payment provider key with full rights.

**Our limits.** At most 60 card requests a minute per project and 8 at once across our server (over that the card is "unavailable" right away); Test 10 times an hour per project.

**Agent checklist.**

1. Check that the project's people are verified (signed `identify()`) and that the owner's plan has the feedback CRM.
2. Add `POST <path>` on the owner's backend: read the raw body, check the signature, parse JSON, find the customer by `person.user_id`, then by `person.email`; `404` when there is none; otherwise build the card and answer `200`. Answer `test: true` requests with a demo card or a real lookup of the typed identity.
3. Read the secret from the server's configuration. Never log the request body, e-mails, ids or signatures.
4. Add a unit test with the test vector above and one with a card that passes the schema.
5. Ask the owner to save the address, issue the secret, put it into the server's configuration themselves and press Test. The integration works only when Test shows "Card received, fields: N". Do not create test people in a production project to check it.

## Ext Ops Panel banner and the verified author mark

**What it is.** Every Chrome extension and AI plugin listed in the public catalog on extops.dev has a banner anyone can put on a site or in a README, with no account: an image from extops.dev inside a link to the product's card, with a few live public facts from the catalog. It says "Listed on Ext Ops Panel". When the product's verified author turns the mark on, the same address says "Verified author on Ext Ops Panel" instead; the snippet does not change. A product the catalog does not know, or whose listing left its store or marketplace, gets the neutral plate (our logo only, same size and theme). No script and no cookie on the host page. Every style, format and theme with live examples: https://extops.dev/docs/author-banners. What "verified" means: https://extops.dev/extensions/methodology#verified-authors.

**Just a banner (any listed product).** Skip to step 3: build the address from the product's card address and the snippet shape below. Steps 1 and 2 are only for the author who wants the "Verified author" mark.

**1. Verify authorship (an account is needed, every plan including Free).**

- *Chrome extension.* The extension is in a project of the user's account and they marked themselves as its author ("I'm the author"). In the project's "Verified author" block they pick one way and get a value:
  - "Website domain": the website named in the store listing. Publish the value as a DNS TXT record at `_extops.<registrable domain>` or as a line in `https://<host>/.well-known/extops-challenge.txt`. Either one is enough. Rechecked every 30 days: keep it in place; 3 failed checks in a row end the verification.
  - "Verified domain": a domain the account already verified for a web service, if it covers the listing's website. Nothing to publish.
  - "Line in the description": `extops-verify=<value>` anywhere in the listing description, for a listing with no website of its own or one on a shared platform (a code host, a store, a social network). Edit it in the Chrome Web Store developer dashboard, wait until the store publishes the update, then check. One-time: the line may be removed after verification.
- *AI plugin.* The plugin is added to a project ("Add an AI plugin") and proven by one of: `extops-verify.txt` with the value at the root of its GitHub repository (default branch), `/.well-known/extops-challenge.txt` on the publisher's domain or the remote endpoint's host, DNS TXT at `_extops.<domain>`, or a domain already verified for a web service. Mirrors and shared hosts prove nothing.
- A value is valid 14 days; we look for it every hour on the first day, then every 6 hours, and "Check now" checks at once. Only one account can be the verified author: a later valid proof by another account takes it over. An extension's verification also ends when the listing changes its publisher, or, for a domain proof, its website.
- Help the user publish the value: add the TXT record in their DNS, commit the file to their repository or site, or tell them exactly where to paste the line. Never invent the value: it comes from the panel.

**2. Consent.** Verification alone changes nothing in public. The switch "Publish me as the author" in the same block (for a plugin, in the project's AI plugin block) turns on, for that one product, the mark on the public card and on every banner, and for a plugin the 30-day availability from its own monitoring. Off by default. Only the user may turn it on: do not do it for them through the API. Turning it off, or a verification that ends, puts the banners back to "Listed on Ext Ops Panel" at once; cached copies update within 6 hours.

**3. Choose and place a banner.** For a verified author, the project's Banner section shows a preview and builds the snippets with the product's name and address; anyone else builds them from the shapes below. The address is a path with no parameters:

```
https://extops.dev/extensions/<extension-id>/banner/<style>/<format>-<theme>.<svg|png>
https://extops.dev/plugins/<family>/<key>/banner/<style>/<format>-<theme>.<svg|png>
```

- `<style>`: `pulse-run` (default), `amber-slab`, `terminal-pro`, `insignia`.
- `<format>`: `badge` (28 px high, width by text; a README badge row, a footer line), `pill` (250×54; a site header or footer, an "As seen on" row), `card` (400×120; under a README title), `wide` (728×90; a full-width header or footer, a README strip), `tile` (300×250; a sidebar).
- `<theme>`: `light`, `dark`, `brand` (amber), `auto` (SVG only; light or dark by the reader's color scheme, good for a GitHub README).
- SVG wherever accepted; PNG (drawn at 2×) only where a platform takes raster images, and then a `<picture>` with `-dark.png` for `prefers-color-scheme: dark` and `-light.png` otherwise.
- `<family>/<key>` is the plugin card's own address (for example `mcp/dev.pdf2md/pdf-to-markdown`); `<extension-id>` is the Chrome Web Store id.

Snippet shapes (take the real ones from the Banner section):

```html
<a href="https://extops.dev/extensions/<extension-id>" title="<name> on Ext Ops Panel">
  <img src="https://extops.dev/extensions/<extension-id>/banner/pulse-run/pill-light.svg" alt="<name>: verified author on Ext Ops Panel" width="250" height="54">
</a>
```

```md
[![<name>: verified author on Ext Ops Panel](https://extops.dev/plugins/<family>/<key>/banner/pulse-run/badge-auto.svg)](https://extops.dev/plugins/<family>/<key>)
```

- Markdown has no width, so use it for `badge` and `pill`; put `card`, `wide` and `tile` into a README as the HTML snippet (GitHub accepts it).
- In `alt`, use `<name>: listed on Ext Ops Panel`, or `<name>: verified author on Ext Ops Panel` once the author has turned the mark on; nothing more. Keep `width` and `height`. The link is the card's canonical address with no parameters; the only options are "open in a new tab" (`target="_blank" rel="noopener"`), `rel="nofollow"` and the card language (`/de/`, `/es/`, `/fr/`, `/pt/`, `/ru/` before `/extensions` or `/plugins`).

**4. Check.** Open the image address: it shows "Listed on Ext Ops Panel", or "Verified author" when the mark is on; the neutral plate means the product is not in the catalog or its listing is gone (never an error, so the page layout holds). For a verified author, the Banner section says why the banner is not showing the mark yet. Banners are cached 6 hours (the plate 1 hour); the facts update by themselves, the snippet never needs editing.

**What the banner shows.** Public card facts only, dated by the catalog pass, above floors (the pride rule): a position only from a public hub (`/extensions/trending`, a category hub, `/plugins/trending`); users and only positive 7-day growth; a rating from 4.0 and 3 ratings; "No site access"; the store's own Featured / Established publisher badges as text; a version updated within 90 days; Manifest V3. For plugins: availability from 99.0% (the author's own monitoring, only with the verified author's consent); "Works with N AI clients"; GitHub stars or weekly npm/PyPI downloads from 10; the marketplaces; the version. A figure that is not published is left out, never shown as zero. Text on the banner is English only.

**What not to do.**

- Do not promise "Verified author" for a product whose author has not verified it and turned the mark on: the banner will say "Listed on Ext Ops Panel". Do not present a "Listed" banner as an endorsement by the product's author.
- Do not add UTM tags, `ref` or other parameters to the card link or the image address (the card redirects any parameter to its clean address; the image ignores the query).
- Do not download and self-host the image, recolor it or rewrite its `alt`: the copy loses live facts and keeps its old wording when the mark changes.
- Do not use a "Listed" banner or the listing badge `/extensions/<id>/badge.svg` as a "verified" mark: anyone can embed them.
- Do not put developer contacts, the verification value or account details into the banner, the snippet or the README text.
- Do not claim that the mark is a security review or a ranking: it says only that the author proved control and chose to show it.

Do not mix first-party events from hosted pages, public Chrome Web Store figures and custom metrics: they have different sources and update delays.
