# @nordhavn/convene

convene as an **embeddable web component**: a private group chat over a mix-net carrier, compiled
from [DCI specs](https://github.com/kopilati/convene-specs) to WebAssembly by
[nord](https://github.com/kopilati/nord). Drop `<convene-chat>` into any page — the whole seat
(login, chat rail, reading, composer) lives inside the element's shadow root and touches nothing
outside it.

```html
<script type="module">
  import "@nordhavn/convene";
</script>

<convene-chat relays="wss://relay.example.org" style="height: 40rem"></convene-chat>
```

No build tool is required — the package is plain ES modules and works from any module-serving CDN.
Under Vite, exclude it from dependency pre-bundling so the wasm asset resolves as shipped:
`optimizeDeps: { exclude: ["@nordhavn/convene"] }`.

## Attributes

- **`relays`** — the relay set: `ws(s)://` URLs separated by whitespace or commas. The seat
  shuffles the set and dials until one answers; any deployed relay is deliberately **one of
  many**, so a host page can offer its own beside everyone else's. Without the attribute the
  carrier is a same-browser BroadcastChannel — a two-tab demo, zero infrastructure, no network.
  A socket that goes — a relay redeploy, a sleeping laptop, a changed network — is redialed with
  backoff and re-registered, which flushes whatever waited in the meantime; the sidebar says where
  the wire stands throughout, because "quiet" and "disconnected" must not look the same. A message
  typed while the wire is down waits in the seat and goes when it returns: the relay never saw it,
  so nothing else could hold it.
- **`user`** — preselects a seat in the picker when this device already holds that id. It cannot
  name a seat into existence: ids are minted, not chosen.
- **`name`** — prefills the shown name on the create form.
- **`specs`** — a URL; when set, the brand row links to the published specs (read at mount).

Size the element with CSS (`display: block` and a height); the seat fills whatever it is given.

## Identity and what rests on the device

**A seat mints its own name.** Registering asks for a shown name and a passphrase; the id is 128
random bits rendered Base58 (the Bitcoin alphabet — no `0`/`O`, no `I`/`l`, safe in a URL or a
JSON string). Nobody chooses it, so nobody can take one that is already held, and two people who
call themselves the same thing are still two seats. That was the point: a chosen username could
collide, and nothing could detect it — the relay keys mailboxes by edge, so a collision silently
merged two strangers' traffic.

An id is public and is the whole of what an invitation needs, so the seat has a **copy id** button
and `Invite…` takes a pasted one. What people see is the shown name; what the machinery uses is
the id.

**A seat lives in one browser.** The passphrase derives an AES-GCM key (PBKDF2, entirely
client-side — no server sees or verifies anything) that seals the ring at rest; IndexedDB holds
the as-arrived cells, still sealed, nothing the wire didn't already carry. A device-level roster
in localStorage lists the seats this browser holds, which is what the login form offers: **logging
in is picking a seat and unlocking it**, never typing an identity. There is deliberately no way to
enter an id this device does not hold — the ring is here and nowhere else, so an id typed on a
fresh machine would yield an empty seat that also displaces the real one at the relay. Clearing
this site's storage ends the seat; the login screen says so.

**Honest test-setup crypto, not bulletproof, by design.** A passkey (WebAuthn PRF) would replace
the passphrase without changing any of the above; the seam is the key derivation, nothing else.

## The relay

A relay is a store-and-forward dead-drop with one mailbox per edge; it sees opaque cell text and
the edge it is for, never a cell's inside. The protocol is three JSON frames over a WebSocket —
`{register: <edge>}` to claim a mailbox, `{to: <edge>, cell: <text>}` to send, `{cell: <text>}`
delivered — small enough to reimplement in an afternoon; the reference implementation is
[`drop-peer.mjs`](https://github.com/kopilati/nord/blob/main/examples/convene/web/drop-peer.mjs)
(~50 lines of Node).

The deployable relay is [`kopilati/convene-relay`](https://github.com/kopilati/convene-relay) —
the same three frames as a **nord-built Rust binary** (the drop's behaviour compiled from its own
DCI drafts; only the socket shell is carried), shipped with a Dockerfile + fly.toml and pinned
always-on because the drop is in-memory. It passes this floor's own two-browser acceptance
standing in for drop-peer.mjs unmodified. The hosted seat is `wss://convene-relay.fly.dev`; the
first embedding consumer is [nordhavn.info](https://nordhavn.info).

---

## Developing (in the nord tree)

This directory is the carried web floor over the unmodified machine crates (core, mix, surface —
all built by nord from the drafts; the boundary is recorded in `../RECONCILIATION.md`). The dev
page `index.html` + `main.js` is a **consumer** of the component — the same page an embedder
writes — with dev conveniences mapped on: `?wire=drop&peer=ws://…` sets `relays`,
`?user=`/`?name=` prefill the login, and `window.convene` bridges the element's API to the
acceptance drivers.

```
./start.sh                            # relay + page server + karin.dahl's browser
./start.sh sofie.lind "Sofie Lind"    # the second member — the daemons are reused
./start.sh --stop                     # tear down what start.sh started
```

Ports via `HTTP_PORT` (8763) and `DROP_PORT` (8764). The wasm rebuilds automatically when
`src/lib.rs` moved (needs `rustup target add wasm32-unknown-unknown` and
`cargo install wasm-bindgen-cli --version 0.2.127`).

### The acceptance tests

```
node test/two_tabs.mjs 8763            # two tabs, BroadcastChannel carrier
node test/two_browsers.mjs 8763 8764   # two BROWSERS through the dead-drop, offline leg included
node test/relay_restart.mjs 8763 8764  # the relay DIES mid-conversation; the seats carry on
node test/identity.mjs 8763 8764       # minted ids, the device's roster, invitation by pasted id
```

`relay_restart.mjs` owns the relay process so it can kill it: a seat is open for hours across a
sleep, a wifi change or a redeploy, and the other drivers all outlive their own infrastructure, so
none of them can see a carrier that never reconnects. `NORD_RELAY=<binary>` judges the Rust relay
instead of the reference peer.

All four drive the same flows the buttons do, through whatever browser `test/browser.mjs` finds —
and `identity.mjs` deliberately drives the FORM rather than the API, because the form is where
registration changed: `convene.login(id, …)` looks identical before and after.

Two more drive **what is deployed** rather than what is in the tree — the published component on
its own page, over a relay on the real network:

```
node test/relay_probe.mjs wss://convene-relay.fly.dev              # the three frames, no browser
node test/deployed_pair.mjs https://nordhavn.info/convene.html     # two peers, the whole life
```

`TESTING_DEPLOYED.md` puts them in order as a ladder — relay, then page, then pair, then the
parts only a human sees — so the first rung that fails names which of the three stacked things
broke.

### Publishing

CI publishes to npm on a `convene-v*` tag (`.github/workflows/convene-publish.yml`): the tag
stamps the version — `package.json` deliberately carries `0.0.0-dev` so the tag is the one
source of version truth, and a publish is a decision, never a merge side effect.

Authentication is **trusted publishing** (OIDC) — no token secret: npmjs.com holds the trust
rule (package **Settings → Trusted Publisher**: `kopilati/nord`, workflow
`convene-publish.yml`, no environment) and the job exchanges its GitHub identity for a
short-lived credential. Two standing accommodations: the trust rule attaches only to an
existing package, so the **first version of a new package name is published once from a
maintainer terminal** (interactive 2FA — no bypass; stamp the version, `npm publish`, restore
`0.0.0-dev`); and **provenance is disabled** in `publishConfig` while this repo is private —
npm cannot attest a private source. Flip it on if the repo goes public.
