# qID Connect integration guide

The address is the account. If your qID integration still asks for an email or
a password, it is wrong. A user proves control of a BTX address with a
post-quantum signature, and that address IS their identity in your app. There
is nothing else to collect. Registration and login are the same action: the
first successful sign-in creates the account.

If you already bolted qID onto email accounts, read
`MIGRATION-from-email.md` and unbolt it.

## What you are integrating

Two pieces, both in this package:

- `@qid/connect-server`: issues challenges, verifies proofs with the frozen
  qID Sign-In v1 verifier, creates or finds the account by address, issues a
  session cookie.
- `@qid/connect-widget`: the "Sign in with qID" button and dialog. One vanilla
  JS file, zero dependencies, works on any site.

Three ways in, all in the same dialog, WalletConnect style:

- Desktop wallet: the user copies the request into the PQ Wallet for BTX v0.21+
  (Settings, qID Sign-In), signs, and pastes the proof back.
- Scan with phone: the dialog shows a QR carrying the same request plus a
  proof URL. A qID mobile wallet scans it, signs, and posts the proof
  straight to your server; the dialog picks the session up automatically.
  There is no relay server anywhere: your own backend is the rendezvous, so
  login keeps working even when every third party is down.
- Open in wallet: a `btxqid://` deep link launches the installed desktop
  PQ Wallet for BTX (v0.27+) with the same request, the user picks a wallet and
  clicks sign, and the proof posts back with no clipboard at all.

## Express quick start

```js
import express from "express";
import { createQidConnect } from "@qid/connect-server";
import { qidMiddleware, requireQidSession } from "@qid/connect-server/express";

const qid = createQidConnect({
  origin: "https://yourapp.com",              // your exact web origin
  sessionSecret: process.env.QID_SESSION_SECRET, // 32+ random chars
});

const app = express();
app.use(express.json());
app.use("/qid", qidMiddleware(qid));           // the whole login system

app.get("/api/me", requireQidSession(qid), (req, res) => {
  res.json(req.qidSession);                    // { address, account }
});
```

## Next.js quick start

One catch-all route file. See `examples/next-demo/` for the complete drop-in.

```js
// app/api/qid/[action]/route.js
import { createQidConnect } from "@qid/connect-server";
import { qidNextHandlers } from "@qid/connect-server/next";

const qid = createQidConnect({
  origin: process.env.QID_ORIGIN,
  sessionSecret: process.env.QID_SESSION_SECRET,
  apiPath: "/api/qid",   // REQUIRED on Next — see below, or QR/deep-link sign-in 404s
});
export const { GET, POST } = qidNextHandlers(qid);
```

On Next the routes live under `/api/qid`, so you **must** pass
`apiPath: "/api/qid"` — it is the path baked into the QR and deep-link proof
URL. Omit it and the phone's proof POSTs to `/qid/proof` and 404s, so QR and
deep-link sign-in silently fail (only copy-paste works). Mount the widget with
the matching `api: "/api/qid"`.

## The button

```html
<div id="signin"></div>
<script type="module">
  import { mountQidConnect } from "/qid-connect-widget.js";
  mountQidConnect(document.getElementById("signin"), {
    api: "/qid",                 // "/api/qid" on Next
    appName: "Your app",
    onSignedIn({ address, account }) {
      // the user is signed in, the session cookie is already set
      location.reload();
    },
  });
</script>
```

Useful options: `transports` sets which tabs the dialog shows and their
order (the first one opens active). The default is
`["qr", "paste", "deeplink"]` — QR first, then desktop copy-paste, then
one-click connect into the installed desktop PQ Wallet for BTX (v0.27+) via
`btxqid://sign`. `wallets` customizes the WalletConnect-style "works with"
list — each entry is `{ name, status, url }`, and giving a wallet a `url` turns
that row into a link to the wallet's page (opens in a new tab), e.g.
`wallets: [{ name: "PQ Wallet for BTX", status: "available", url: "https://pq-wallet.com" }, { name: "bonuz Wallet", status: "available", url: "https://bonuz.xyz/btx-wallet" }]`.
`signingFx: false` disables the brief signing animation. `buttonText`
relabels the button (e.g. for localization; default "Sign in with qID"), and
`pollIntervalMs` sets the QR/deeplink poll cadence (default 1500).

Two ways to get the widget file:

- **Hosted (zero maintenance):** `import { mountQidConnect } from
  "https://qid.dev/connect/widget.js"` — one file, nothing to vendor, and
  wallet-list/UX updates reach your users automatically when we deploy.
  The signed protocol underneath is frozen v1 and never changes.
- **Vendored (pinned):** copy `packages/widget/src/` into your app as the
  examples do. You control exactly what ships; update on your schedule.
  A version-pinned hosted copy also exists (`widget-<version>.js`).

The dialog is dynamic: every sign-in request shows a countdown and rotates
itself shortly before its display lifetime (about two minutes) ends. On
rotation the widget passes the superseded nonce back to `POST /challenge` as
`{ retire }` and the server burns it, so a screenshotted or stale request
stops being acceptable the moment it leaves the screen. You get this for
free; there is nothing to configure.

## The signed-in state (the account chip)

After sign-in, render the account chip so the signed-in UI is consistent across
every BTX app — the same reason the dialog is:

```html
<div id="signin"></div>    <!-- the Sign in button -->
<div id="account"></div>   <!-- the signed-in chip (in your header) -->
<script type="module">
  import { mountQidConnect, mountQidAccount } from "/qid-connect-widget.js";
  const account = mountQidAccount(document.getElementById("account"), {
    api: "/qid",                         // "/api/qid" on Next
    onSignedOut() { location.reload(); },
  });
  mountQidConnect(document.getElementById("signin"), {
    api: "/qid",
    onSignedIn() { account.refresh(); }, // reveal the chip with no reload
  });
</script>
```

`mountQidAccount` fetches `GET /qid/session` and renders a chip (green dot +
short address) only when signed in — nothing when signed out, so mount it
unconditionally. Clicking it opens a dropdown with the full address (click to
copy), any `menuItems` you pass, an optional `explorer(address) => url` link,
and **Log out** (POSTs `/qid/logout`, a deliberate two-step). Options:
`{ api, onSignedIn, onSignedOut, menuItems: [{ label, onClick(session), danger }],
explorer, logoutLabel }`. Returns `{ refresh, unmount }`. The `menuItems` slot
is where richer identity (e.g. linked social accounts) will plug in later.

## The endpoints you get

| route | what it does |
|---|---|
| `POST /qid/challenge` | issues a fresh one-time challenge bound to your origin, plus the poll secret for QR sign-in; an optional `{ retire: <oldNonce>, retireSecret: <oldPollSecret> }` body (sent by the rotating widget) burns the superseded nonce — the secret proves the caller was the browser that was issued it |
| `POST /qid/verify` | verifies a proof pasted in this browser, creates or finds the account by address, sets the session cookie |
| `POST /qid/proof` | a remote signer (phone wallet) submits its proof here; it gets ok or a reason, never a session |
| `GET /qid/poll` | the browser that issued the challenge claims the session with its poll secret once the remote proof lands |
| `GET /qid/session` | returns `{ address, account, expiresAt }` for the current session, else 401 |
| `POST /qid/logout` | clears the session cookie |

On Next.js the same six routes live under `/api/qid`; pass
`apiPath: "/api/qid"` to `createQidConnect` so the QR points phones at the
right proof URL.

`verify` failure reasons: `nonce_unknown`, `nonce_used`, `origin_mismatch`,
`expired`, `not_yet_valid`, `bad_signature`, `bad_proof_shape`, `bad_origin`.
The widget already turns these into user-friendly text.

`bad_origin` comes from the built-in login-CSRF guard: `/verify` rejects a
request whose `Origin` header is present and does not match your configured
origin, so a cross-site page cannot POST a proof into a visitor's browser and
sign them into someone else's account. The widget always calls same-origin,
so this is invisible in normal use. `/proof` is intentionally exempt because
the cross-device QR flow is legitimately cross-origin; it is protected by the
single-use origin-bound proof instead. If you sit qID Connect behind a proxy
that rewrites `Origin`, make sure it forwards the real browser origin.

## The account model

`accounts` is a two-function adapter and the address is the primary key:

```js
const accounts = {
  async getOrCreate(address) { /* INSERT ... ON CONFLICT DO NOTHING; SELECT */ },
  async get(address) { /* SELECT */ },
};
```

Your table needs one required column: `address TEXT PRIMARY KEY`. Add optional
profile fields if you want them (display name, avatar). Do not add an email
column for identity.

When the call comes from a verified proof, `getOrCreate` receives a third
argument `{ proof, recoveryLeafHash }`. `recoveryLeafHash` is the proof's
`recovery_leaf_hash`, already authenticated by the verifier (the address
rebuild covers it) and stable across login-key rotation, so it is the right
identity key for one-per-wallet rules. The session `poll` step may call
`getOrCreate` without context for an account the proof submission just
created; never clear a stored field on a context-free call. Ignore the
argument entirely if the address is all you need. If your app genuinely needs to send email later
(receipts, alerts), collect it after sign-in as optional contact data, never
as a login credential.

## Production checklist

- `origin` must be your exact origin, lowercase, no path, no trailing slash.
  This is what makes a proof for another site worthless on yours.
- **The origin must be the host users actually land on.** If your site
  canonicalizes apex ↔ www (most do — at the CDN/host, not in your code) or
  http → https, `origin` must be the *final* origin after those redirects,
  or `/verify` rejects every proof with `bad_origin` and sign-in silently
  breaks. Example: a site that 308-redirects `example.com` → `www.example.com`
  must set `origin: "https://www.example.com"`. When in doubt, open your site
  and read `location.origin` in the console — that exact string is your origin.
- One server instance, or a shared store. The challenge/poll rendezvous is
  stateful: the instance answering `/qid/poll` must know the nonce that
  `/qid/challenge` issued. The in-memory default is single-process only —
  on serverless or multi-instance deployments use the SQLite stores on a
  shared volume, or back the store adapters with your database. The symptom
  of getting this wrong: the QR renders, then flips to "expired" within
  seconds. (The widget auto-recovers once and reports the exact diagnosis
  through `onError`.)
- Runtime prerequisites on a bare server: `bun install` needs `unzip`
  present (`apt-get install -y unzip`), or run the server on Node 20+ with
  the Node SQLite path. See `examples/static-rewrite-demo/` for a complete,
  runnable server + rewrite recipe.
- `sessionSecret`: 32+ random characters from a secrets manager. Rotating it
  signs everyone out, which is also your kill switch.
- Production state must be durable. The package ships a SQLite adapter
  (`SqliteNonceStore`, `SqliteAccounts` in `packages/server/src/sqlite.js`)
  that works with bun:sqlite, node:sqlite, or better-sqlite3; use it instead
  of the in-memory defaults on any real deployment. For multi-instance
  deployments implement the same two interfaces on Redis or your SQL server
  (documented in `stores.js`) and validate your adapter with the conformance
  suite in `packages/server/test/stores.test.js`.
- Serve over HTTPS. The session cookie is HttpOnly, SameSite=Lax, and Secure
  when your origin is https.
- **Same-origin proxy of `/qid/*` is fine — and is required if your server
  is on another host.** The rule is narrow: never let a proxy change the
  origin the wallet sees versus the one the user visits.
  - ❌ BAD: a rewrite that changes `rp_origin` — the server issues
    `rp_origin = api.example.com` while users are on `app.example.com`, so
    the wallet shows the wrong site.
  - ✅ GOOD: a *transparent same-origin* rewrite of `/qid/*` where the
    server's `origin` is set to the page's canonical origin and the proxy
    forwards `Origin` (and `Sec-Fetch-Site`). This is the standard JAMstack
    pattern — a static page on a CDN, a small backend on another host — and
    it works. Because the session is a first-party HttpOnly cookie, the
    server **must** be reachable under the page's own origin (via that
    rewrite); calling it cross-origin will not set or send the cookie.
    `examples/static-rewrite-demo/` ships the whole recipe (Vercel, Netlify,
    Caddy, nginx).
## QR sign-in, what keeps it honest

The QR flow is the classic cross-device login, so it inherits the classic
cross-device caution. The proof itself stays origin-bound, single-use, and
unable to move funds. The one scenario to respect is an attacker showing a
victim a QR from the attacker's own browser session; if the victim signs it,
the attacker's browser gets signed in as the victim. Three things contain
this:

- The poll secret never appears in the QR. Only the browser that requested
  the challenge can claim the session; a bystander photographing the QR gets
  nothing.
- `GET /poll` mints the session cookie, so it carries the same login-CSRF guard
  as `/verify`: the SDK rejects a poll that the browser marks as not
  same-origin (`Sec-Fetch-Site`), before it can claim the nonce. This stops an
  attacker who holds their own `(nonce, secret)` from driving a victim's browser
  to a cross-site `/poll` and planting the attacker's session. The widget always
  polls same-origin, so this is invisible in normal use. If you put qID Connect
  behind a proxy, make sure it does not strip `Sec-Fetch-Site`.
- Conforming signers refuse any request whose proof URL is not the same
  origin as the site being signed in to, so a hostile QR cannot show a real
  site and route the proof elsewhere. The server SDK already emits
  same-origin proof URLs; do not proxy them through another domain.
- The signing wallet must present it plainly: "You are signing in to
  {origin} on another device." That rule is part of the qID signer standard.
- Your app should treat QR sign-in like any remote login: show the address a
  session belongs to, and let users revoke sessions.

## Why this is safe to offer

A qID sign-in proof is an ML-DSA-44 signature over a tagged hash in the login
domain (`BTX-qID/login-v1`). Transactions sign in a different domain
(`TapSighash`). The two cannot collide, so a login proof can never move funds,
no matter what a malicious site does. Full treatment in the qID Sign-In v1
package (`SPEC.md`, `SAFETY.md`).

Verify your copy of the verifier once after install:

```
bun test packages/server/test
```

This checks the pinned verifier hash and runs the full frozen v1 test vector
set against it.
