# DinastiAPI Connector
An embeddable, dependency-free widget that lets any app (the DinastiAPI 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) DinastiAPI (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 | DinastiAPI 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
[DinastiAPI 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
DinastiAPI origin and only accepts messages from its embedder.
- **The extension never sees any DinastiAPI token** — only a challenge that
originated on the DinastiAPI 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 DinastiAPI 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.