# wazmeow Connector An embeddable, dependency-free widget that lets any app (the wazmeow dashboard or a third-party SaaS) pair a WhatsApp number — **QR**, **phone code**, or **passkey** — without ever exposing the real instance token to the browser. It is modeled on the z-api connector. ```html ``` --- ## Architecture ``` Partner app (any stack) wazmeow (self-host) backend ──POST /session/connector/token (REAL token)──► mint ephemeral (10min) │ ephemeral token browser ◄───────────┘ ``` `ZZ.connector` (alias `ZZGConnector`) API: | Method | Description | |--------|-------------| | `open(opts) → Promise` | Opens the modal, drives pairing. Resolves `true` when paired (loggedIn), `false` if closed first. | | `close()` | Tears down the modal. | | `on(ev, fn)` / `off(ev, fn)` | Global event listeners. | ### Options | Option | Default | Notes | |--------|---------|-------| | `token` | — | **Required.** Ephemeral token. | | `baseUrl` | same origin | wazmeow host (cross-origin embeds). | | `theme` | `"auto"` | `light` \| `dark` \| `auto` (follows `prefers-color-scheme`). | | `locale` | `"pt"` | `pt` \| `en`. | | `methods` | all true | `{ qr, phone, passkey }` toggles. | | `messages` | — | Override any i18n string (deep-merged over the locale). | | `container` | `document.body` | CSS selector or element to mount into. | | `pollIntervalMs` | `2000` | Status polling cadence. | | `subscribe` | `["Message"]` | Events subscribed on connect. | | `autoConnect` | `true` | POST `/connector/connect` on open. Set `false` when the instance is already connected. | | `extensionInstallUrl` | `{}` | `{ chrome, firefox }` install links for the passkey CTA. | | `bookmarkletUrl` | `/passkey-bookmarklet.html` | Fallback link. | ### Events `open`, `status`, `qr`, `connected`, `error`, `close`. Each also maps to an `on` callback in `open(opts)` (e.g. `onConnected`, `onStatus`, `onError`). `status` fires on every poll with the raw `/connector/status` payload. ### Theming & i18n Theme uses CSS custom properties inside a Shadow DOM (fully style-isolated from the host page). Override any label: ```js ZZ.connector.open({ token, locale: "en", messages: { title: "Link your WhatsApp", tabPasskey: "Security key" }, }); ``` --- ## Passkey — "covers all cases" for free Some accounts require a **passkey** instead of a QR/phone code. WebAuthn binds the credential to the Relying-Party origin: WhatsApp's passkey is bound to `whatsapp.com`, and the browser only runs `navigator.credentials.get()` from a page whose origin is `https://web.whatsapp.com`. **Neither the dashboard nor an embed on our origin can run that ceremony.** So the connector is a *bridge*: 1. **Probe** — `POST /connector/passkey/challenge` forces WhatsApp to hand over the WebAuthn request options on demand (deterministic — no race against the reactive event). Accounts outside the passkey cohort get `503 passkey_unavailable`. 2. **Bridge** — a hidden iframe (`/connector.html`) relays a request to the [wazmeow passkey extension](../zzg-passkey-extension/) (content script on `web.whatsapp.com`). 3. **Solve** — the extension runs `navigator.credentials.get()` with the server's `publicKey` options **verbatim** and returns the assertion. 4. **Submit** — `POST /connector/passkey/response`, then `confirm` (or auto when `skipHandoffUX`), then the poll loop sees `loggedIn`. Because we pass the server's `publicKey` options straight to `navigator.credentials.get()`, the browser/OS presents **whatever the device supports** — Touch ID, Windows Hello, a security key, or a phone over hybrid/Bluetooth. There is no per-case handling: the universality is the standard WebAuthn API's, not ours. ### Solver options (in order of preference) 1. **Extension** (`zzg-passkey-extension/`) — MV3 for Chrome/Edge/Brave, `.xpi` for Firefox. Load unpacked in dev; see its README. Keep a `web.whatsapp.com` tab open. 2. **Bookmarklet** (`/passkey-bookmarklet.html`) — zero install, works on any browser/OS (including Windows). Accepts both real tokens and ephemeral `ct_…` tokens (auto-routes to `/connector/*`) and can be deep-linked with `?apiUrl=…&token=…` to prefill. The widget detects the extension via the bridge; if absent, it shows the install links + the bookmarklet fallback. --- ## Security - **Real token never reaches the browser.** Mint server-side; only the ephemeral token is public. It is scoped to `/connector/*` by routing. - **CORS allowlist.** Cross-origin embeds require the partner origin in `CONNECTOR_CORS_ORIGINS` (comma-separated; `*` for dev only). Preflight is answered before auth. Origins not on the list get no CORS headers, so the browser blocks the request. - **Ephemeral TTL.** `CONNECTOR_TOKEN_TTL` (default 600s). A pruner removes expired tokens every minute. - **Mint rate limit.** `CONNECTOR_TOKEN_RATE_PER_MIN` (default 60) per real token. - **Origin isolation.** The bridge iframe validates `event.origin` against the wazmeow origin and only accepts messages from its embedder. - **The extension never sees any wazmeow token** — only a challenge that originated on the wazmeow server, returning only a signed assertion. ### Env vars ```env CONNECTOR_CORS_ORIGINS=https://app.partner.com,https://other.com # empty = same-origin only; * = dev CONNECTOR_TOKEN_TTL=600 # ephemeral TTL (seconds) CONNECTOR_TOKEN_RATE_PER_MIN=60 # mint cap per real token per minute ``` --- ## Operational notes & known limits - **In-memory state.** Ephemeral tokens and passkey state live in process memory. They are lost on restart and are **not shared across replicas**. For a multi-replica deployment, pin connector traffic to one replica (sticky) or move the store to Redis (future work). Documented, accepted for this release. - **Handoff window.** WhatsApp's passkey handoff proof expires ~5 minutes after the challenge. If it lapses, reconnect to get a fresh challenge. - **Passkey rate limit.** `POST /passkey/response` is limited to one submission per user per 5s. If a user cancels the OS prompt and retries immediately, wait a moment before resubmitting. --- ## Live validation runbook The full fiber is in place, but WhatsApp's **server-side acceptance of the passkey assertion** can only be proven live (valid assertion, correct origin, inside the handoff window). To validate: 1. Start wazmeow with `CONNECTOR_CORS_ORIGINS` including your example app's origin (and, if testing cross-origin, the example's origin). Load the `zzg-passkey-extension/` unpacked and keep a `web.whatsapp.com` tab open. 2. **Dashboard:** open `/dashboard/`, pick the instance → **Connect**. For a passkey account, the card's **Resolve Passkey** button opens the widget, which probes the challenge, detects the extension, runs the ceremony, and submits. 3. **Third-party:** open a demo from `examples/connector/` (or `/connector-demo/`) on another origin → mint → `ZZ.connector.open` → pair the same number (normal via QR/phone; passkey via the extension/bookmarklet). 4. **Debug (server logs):** `passkey_prologue_request` → `/connector/passkey/response` 200 → `crsc_continuation` → confirm → `PairSuccess` → `loggedIn`. If the server rejects the assertion, inspect the `clientDataJSON` origin, flags, and `signCount` (capture with `WA_PASSKEY_CAPTURE=1`). > ⚠️ **Not yet validated against a live passkey account.** Everything up to the > assertion submission is code-complete and testable; the final acceptance depends > on a real passkey account and is the remaining P0. The bookmarklet fallback > exists precisely so pairing is possible even if the extension path needs > iteration on a specific device.