# 🤖 Connecting third-party services (`/register`, and friends)

Status: **prototype shipping.** A connect broker is live at
`connect.elevenmessenger.com` and `test-bots.example.com` is wired to it (branch
`connect-oauth`). DNSimple is the worked example; the shape generalises to any
OAuth API. **The plan evolved during implementation** — DNSimple turned out to be
confidential-only, so the public/PKCE sections further down are **superseded** by
*What shipped* immediately below.

> **Generalised since:** the single-service proof below grew into the
> **integrations registry** — services as reviewed JSON manifests (auth + a
> pinned host + a closed, read/write-scoped op list with response
> projections), a generic executor, a catalog UI, and `service_op` consent
> chips. New services (GitHub is the first) are added as manifests, not Go
> code. See **`docs/integrations.md`**; this document remains the trust-model
> reference for credential custody, grants, and the broker.

## The goal

A **seamless, out-of-the-box** "Connect a service" experience on a *fresh*
instance — a member clicks Connect (or pastes a token), and afterwards a command
like `/register domain.com` just works — **without** weakening the load-bearing
rule (`CLAUDE.md`): if the messenger server is compromised, the attacker gets
nothing usable.

"Anyone can already `scp` a `DNSIMPLE_TOKEN` onto a bot" is exactly what we're
*not* building. That path is manual, per-operator, and puts a live secret on the
box forever. We want connect-through-the-app as a first-class flow.

## What shipped: DNSimple is confidential-only → the connect broker

Implementation reality overrode the PKCE plan below: **DNSimple offers no
public/PKCE client** — every OAuth app is *confidential*, with a `client_secret`.
So there IS a secret. Rather than copy it onto every instance (the old
`.p8`-on-every-box problem), it lives in **one shared broker** — the same move as
the push relay.

The **connect broker** (`broker/`, deployed as `connect.elevenmessenger.com`)
holds the `client_id` + `client_secret` and does two jobs:

- **Redirect relay** (`GET /oauth/dnsimple/callback`) — the single registered
  redirect_uri for the whole fleet. DNSimple redirects here with a one-time
  `code`; the broker bounces it back to the originating instance, read from an
  **allowlisted** `state` (`base64url(origin).nonce`). It sees only a single-use
  code → **safe if compromised**.
- **Token exchange** (`POST /api/exchange`) — a bot sidecar sends `{code, state}`
  with a per-instance bearer token; the broker swaps it for an access token using
  the secret and returns it. It stores nothing; the sidecar seals the token to the
  member. This is the one place holding the secret + transiently seeing a token.

Instances fetch the public params (`client_id`, `authorize_url`, `redirect_uri`)
from the broker's `GET /config`, so **a stock instance needs no DNSimple config at
all**; the broker URL defaults to `connect.elevenmessenger.com`
(`LCHAT_CONNECT_BROKER_URL` overrides for a self-hosted broker).

The sidecar side is ported to Go (`internal/bot/connect.go` + `dnsimple.go`), so
`lchat bot run` handles `connect.op`/`connect.oauth` with no Node — same env
(`LCHAT_CONNECT_BROKER_URL`/`_TOKEN`, `LCHAT_DNSIMPLE_BASE`), same wire shapes.
`agent/dnsimple.mjs` + `runtime.mjs` remain the reference.

| Compromised | Holds | Usable to an attacker? |
|---|---|---|
| Redirect relay (public endpoint) | nothing | single-use codes only → **no** |
| Messenger instance | opaque ciphertext blobs | **no** |
| Exchange broker | `client_secret` + in-flight tokens | secret is *app-identity* (impersonation, not account access); tokens in-flight only, never stored |
| Per-instance sidecar | a user token transiently (to seal it) | scoped to that instance |

The `client_secret` is *app-identity auth* — same tier as the admin token / APNs
`.p8`. A leak enables impersonation in an OAuth prompt, not access to anyone's
account (DNSimple still requires the user's consent to mint a token).

### Self-hosting: on-the-fly broker registration (planned, not built)

Today the broker admin hand-mints a per-instance token (fine for our fleet, but a
barrier for a self-hoster pointing at our broker — they'd have to ask us). The
plan is **dynamic registration with a domain-control proof**: an instance POSTs
its origin to the broker; the broker verifies the instance actually serves at that
origin (ACME/HTTP-01-style — fetch a challenge from
`https://<origin>/.well-known/lchat-connect`); on success it auto-allowlists the
origin and issues a token. No human in the loop, and safe: **you can only register
an origin you control**, so no open-redirect and no code hijacking. (A self-hoster
running their *own* broker needs nothing from us.)

### Connected services as installed apps (prototyped)

Once connected, a service *installs as its own app*. The DNSimple app
(`web/static/apps/dnsimple.js`) is the working proof-of-concept: it appears in
New chat **only after** you've linked DNSimple (a new `requiresService` gate on
the app spec) and opens as its own thread showing a **table** of your domains
(name → status, expiry, renewal price, auto-renew) — filterable, keyboard-navigable
(↑/↓/Enter to open a domain on dnsimple.com), with a Refresh. It's a **takeover**
app: the table *is* the thread, with no chat view, so a fetch never echoes the
list into a chat bubble. (Conversational AI — "register this domain" — would
reintroduce a chat view later, once there's a bot to actually converse with.)

It's a **client-side mini-app**, a "dynamic bot" — **no per-service sidecar**.
DNSimple's API has no browser CORS (confirmed — same as the token endpoint), so
the app can't call it directly. It **delegates each operation to the existing
per-instance sidecar via a time-boxed grant**: `ctx.host.connected.requestData`
seals the token to the bot for one op, the sidecar makes the call and answers
with a non-rendering `connect.result` (correlated by a request id), and the app
folds the result into state (which syncs across your devices). The bot **never
posts the result as a message** — it only returns the invisible `connect.result`,
so connected-service data never surfaces in the bot's DM. *Not* the broker:
proxying per-call there would make the shared broker see every user's token on
every call (breaking "shared infra leaks nothing"). On **native** there's no CORS
wall, so the app could later call the API directly.

Known follow-ups from this POC:
- **No slash command.** The Interface view *is* the domain list (auto-loaded, with
  a filter + Refresh + per-domain links to dnsimple.com), so the app registers no
  `/list` — a verb would be redundant, and routing it through the bot only risks
  the bot speaking. If context-local commands are wanted later, the generic
  resolver (every app claims a plain verb, resolved by the thread you're in) is the
  follow-up.
- **Per-bot "handles connect" capability flag** to replace `findBotThread`'s
  name-ranking of Eleven/Claude.
- **Sidebar gating is one-directional.** `requiresService` hides the app from
  New chat until connected, but a thread already created stays in the sidebar if
  you later disconnect (the app then shows a "not available" state).

Longer term these companion apps should be **installable and maintained outside
this repo** rather than baked in.

## The one split that unlocks it: storage vs. execution

- **Storage** — where the credential rests, and in what form.
- **Execution** — which process actually calls DNSimple, and whether it needs the
  credential *in the clear* at call time.

The core rule only binds the **messenger instance server**: it may hold, at most,
an opaque encrypted-at-rest blob it cannot open. It says nothing about a
*different* trust domain (the user's browser, the bot sidecar) holding the
credential. So the whole design is: **keep the usable credential off the instance
server; decide which other domain holds it, and for how long.**

### The one hard truth

To call DNSimple, *something* must hold the token in the clear at that instant.
So any scheme can bound **when the token is acquired** — it can never bound how
long a dishonest/compromised holder **retains** what it already saw. **You cannot
un-see plaintext.** Every guarantee below is "bounded blast radius," never
"zero-knowledge."

## Who sees what

| Party | Sees |
|---|---|
| Messenger instance server | only an opaque ciphertext blob (the wrapped token) + wrapped grants — never the token, never a usable key |
| The user's browser | the token in the clear, only while the user is present (unwraps with their key / passkey) |
| The bot sidecar | the token in the clear **only during an open, time-boxed grant** — nothing between windows |
| Redirect bouncer (OAuth path only) | a single-use authorization `code` in transit — **useless without the PKCE `code_verifier`, which never leaves the browser** |
| Nobody | a client_secret (there is none — see OAuth below), or a standing plaintext token on the instance server |

## Token custody — temporal, client-encrypted

The token never rests in usable form on any server. It's stored client-encrypted
and only ever decrypted with user presence; the bot receives short-lived,
re-wrapped **grants**, never the long-lived secret at rest.

1. Browser generates a random `DEK`; `C = AES-GCM(DEK, token)` → stored
   server-side as an opaque blob (rule-safe).
2. `DEK` is wrapped to the **user's** key (or a passkey-derived KEK via the home
   server) and stored — only a client with user presence can unwrap it.
3. On "sign in," the browser unwraps `DEK` and **re-wraps it to the bot's public
   key with an expiry**: `G = ECIES(bot_pub, DEK ‖ exp)`, stored server-side
   (opaque). This reuses the exact ECDH-P256 + ECIES wrapping already in
   `crypto.js` — the bot is already a member with a keypair.
4. The bot unwraps `DEK`, decrypts `C`, acts; it drops `DEK`/token at `exp` and
   refuses to act without a fresh `G`.
5. Every N hours the user re-authenticates → fresh `G`, new `exp`.

`exp` is enforced by the bot honouring it: a compromise *during* a window gets the
token; a compromise *between* windows finds only ciphertext. The gain over a
static env token is real — exposure is bounded to *currently-open windows* — but
it is not the E2E "compromise = nothing" guarantee. Say so honestly wherever this
surfaces to users.

### The home server is the natural store + re-auth clock

"Sign in every N hours" **is a passkey ceremony**, and the home server already has
all of it: passkey PRF → HKDF → KEK (requires user presence, never leaves the
client), ceremony proofs, per-passkey DEK wrapping. Its README already reserves
the blob for "later, credentials." So home holds the encrypted token; the passkey
ceremony *is* the periodic re-auth; the browser re-issues the bot's grant. Home
stays a **vault, never the actor** — that keeps its anonymity and content-blind
invariants intact.

## Interactive vs. autonomous — often the whole problem dissolves

- **Interactive commands** (`/register domain.com` — a human typed it, so a
  browser with the key is present): issue a **one-shot, single-operation grant**
  (or call via a dumb proxy) just for that command. No standing window, minimal
  exposure. This covers the headline UX.
- **Autonomous tasks** (renew a domain at midnight, watch DNS while you sleep):
  these genuinely need standing access → the temporal window above, tuned to the
  feature's tolerance.

## The OAuth question: no shared secret store needed

DNSimple's OAuth supports **public clients with PKCE** — `client_secret` is
*"Required for confidential clients. Ignored for public clients."* Register Eleven
Messenger as a **public client** and:

- **`client_secret`: does not exist.** Nothing to centralise, nothing to protect.
  The browser generates a per-login `code_verifier`, sends `code_challenge`, and
  proves it at token exchange.
- **`client_id`: not a secret.** It rides in the authorization URL in the clear —
  plain config (bake it in, or serve from a trivial endpoint).

The *only* thing OAuth pushes toward a shared component is the **`redirect_uri`**:
DNSimple requires it pre-registered (exact URI or subdirectory; OAuth apps are
registered by hand, no mint-per-instance API), and you can't pre-register every
subdomain. The seamless answer is **one once-registered redirect origin + a
stateless redirect bouncer**: the instance starts the flow with a `state`
identifying itself; DNSimple redirects to the shared callback; the bouncer bounces
the `code` back to the originating instance, where the **browser** does the PKCE
exchange.

That bouncer **holds no secret and never sees a token.** It sees a single-use
`code` in transit — useless without the `code_verifier` that never leaves the
browser. It's a dumb relay (closer to a DNS record than a vault); it can live
anywhere and must **not** be the home server (which is deliberately
instance-decoupled and anonymous). Native apps (iOS/Mac) can use a loopback /
custom-scheme redirect and skip the bouncer entirely, like DNSimple's own CLI.

## Two paths, both without a secret or token vault

1. **Paste a scoped PAT (recommended v1).** DNSimple account/user tokens are
   **scopable** to specific domains/zones + resource types (registrar, zones, …) —
   least-privilege by construction. Captured in the browser, dropped straight into
   the temporal scheme. **Zero shared infrastructure.**
2. **OAuth PKCE public client (v2, UX upgrade).** User clicks Authorize instead of
   pasting. Needs only the public `client_id` + the stateless bouncer (web only;
   native skips it). Resulting token enters the same temporal scheme.

## Honest costs / open decisions

- **Window length is a trade-off.** Short = less exposure but autonomous features
  stop if the user doesn't re-auth (a domain could lapse — bad); long = keeps
  working unattended but widens the window. **Warn before a window closes.**
- **Expiry ≠ revocation.** Closing a window doesn't undo a leak that already
  happened inside it — "Disconnect" must **rotate/revoke the token provider-side**,
  not just delete the blob.
- **Least privilege.** Prefer the narrowest token the provider offers (DNSimple
  scoped tokens); a broad registrar token is domain-takeover if it leaks mid-window.
- **Authz for shared credentials.** A group-shared account means "who may run
  `/register`" is a policy the bot must enforce — the admin token authorises roster
  management, not spending money on domains. New surface to design.
- **Confirmation.** `/register` spends money and mutates the world; per CLAUDE.md a
  command answers in place — irreversible actions confirm via an **action chip**,
  never silent execution.

## Command namespacing (why a connected service is an *app*)

A connected service's commands want generic verbs — `/list` should list *your
domains* inside the DNSimple app. But today command registration is a single
global map (`apps.js` `commandOwner`, last-write-wins): the moment two apps want
`/list`, the second silently clobbers the first. Convention (every app uses its
own name: `/todo`, `/poll`) has hidden this; generic verbs break it.

The fix fits the architecture we already have: **a thread is owned by an app**
(takeover) or **has a companion app**. That's the scope. Commands resolve
**context-local first + a namespaced alias**:

- Inside the DNSimple app: bare `/list` → DNSimple's list. No *global* `/list`
  exists, so no other app can clobber it — each app owns its own verbs.
- From any chat: `/dnsimple list` (the namespaced alias, Slack-familiar — `/jira
  create`) always resolves unambiguously.
- Only a small, explicit set stays global: bot/system commands (`/help`,
  `/theme`).

This is why a connected service is a **first-class app** (a companion view on the
bot thread, or its own app-thread), not just a bot you DM: the app owns the
commands and the rendering (a place to *show* your domains); the bot stays the
*actor* that holds the grant and calls the API.

Implementing this means moving command resolution off the global map onto
per-app tables keyed by the current context (`parseCommand`/`runCommand`/the `/`
typeahead), and migrating the existing apps — a focused change to the core
command system, tracked as its own follow-up. **Interim (this PR):** `/list` is
registered globally and DNSimple owns it because nothing else claims it; the
follow-up scopes it and adds the `/dnsimple list` alias.

## Phasing

Ship **v1 on scoped PATs** — no shared infra, least-privilege, and it forces the
temporal-storage + bot-grant mechanics to be right. Layer **OAuth PKCE** on later
purely as UX: one public OAuth app + the stateless bouncer for web. At no point is
there a server holding a `client_secret` or a pile of user tokens.

---

Sources (DNSimple specifics, verified 2026-07):
[OAuth API](https://developer.dnsimple.com/v2/oauth/) ·
[CLI browser login (PKCE + loopback)](https://blog.dnsimple.com/2026/06/dnsimple-cli-browser-login/) ·
[Scoped access tokens](https://blog.dnsimple.com/2023/11/scoped-access-tokens/) ·
[API access token](https://support.dnsimple.com/articles/api-access-token/)
