| 1 | # π€ Connecting third-party services (`/register`, and friends) |
| 2 | |
| 3 | Status: **prototype shipping.** A connect broker is live at |
| 4 | `connect.elevenmessenger.com` and `test-bots.example.com` is wired to it (branch |
| 5 | `connect-oauth`). DNSimple is the worked example; the shape generalises to any |
| 6 | OAuth API. **The plan evolved during implementation** β DNSimple turned out to be |
| 7 | confidential-only, so the public/PKCE sections further down are **superseded** by |
| 8 | *What shipped* immediately below. |
| 9 | |
| 10 | > **Generalised since:** the single-service proof below grew into the |
| 11 | > **integrations registry** β services as reviewed JSON manifests (auth + a |
| 12 | > pinned host + a closed, read/write-scoped op list with response |
| 13 | > projections), a generic executor, a catalog UI, and `service_op` consent |
| 14 | > chips. New services (GitHub is the first) are added as manifests, not Go |
| 15 | > code. See **`docs/integrations.md`**; this document remains the trust-model |
| 16 | > reference for credential custody, grants, and the broker. |
| 17 | |
| 18 | ## The goal |
| 19 | |
| 20 | A **seamless, out-of-the-box** "Connect a service" experience on a *fresh* |
| 21 | instance β a member clicks Connect (or pastes a token), and afterwards a command |
| 22 | like `/register domain.com` just works β **without** weakening the load-bearing |
| 23 | rule (`CLAUDE.md`): if the messenger server is compromised, the attacker gets |
| 24 | nothing usable. |
| 25 | |
| 26 | "Anyone can already `scp` a `DNSIMPLE_TOKEN` onto a bot" is exactly what we're |
| 27 | *not* building. That path is manual, per-operator, and puts a live secret on the |
| 28 | box forever. We want connect-through-the-app as a first-class flow. |
| 29 | |
| 30 | ## What shipped: DNSimple is confidential-only β the connect broker |
| 31 | |
| 32 | Implementation reality overrode the PKCE plan below: **DNSimple offers no |
| 33 | public/PKCE client** β every OAuth app is *confidential*, with a `client_secret`. |
| 34 | So there IS a secret. Rather than copy it onto every instance (the old |
| 35 | `.p8`-on-every-box problem), it lives in **one shared broker** β the same move as |
| 36 | the push relay. |
| 37 | |
| 38 | The **connect broker** (`broker/`, deployed as `connect.elevenmessenger.com`) |
| 39 | holds the `client_id` + `client_secret` and does two jobs: |
| 40 | |
| 41 | - **Redirect relay** (`GET /oauth/dnsimple/callback`) β the single registered |
| 42 | redirect_uri for the whole fleet. DNSimple redirects here with a one-time |
| 43 | `code`; the broker bounces it back to the originating instance, read from an |
| 44 | **allowlisted** `state` (`base64url(origin).nonce`). It sees only a single-use |
| 45 | code β **safe if compromised**. |
| 46 | - **Token exchange** (`POST /api/exchange`) β a bot sidecar sends `{code, state}` |
| 47 | with a per-instance bearer token; the broker swaps it for an access token using |
| 48 | the secret and returns it. It stores nothing; the sidecar seals the token to the |
| 49 | member. This is the one place holding the secret + transiently seeing a token. |
| 50 | |
| 51 | Instances fetch the public params (`client_id`, `authorize_url`, `redirect_uri`) |
| 52 | from the broker's `GET /config`, so **a stock instance needs no DNSimple config at |
| 53 | all**; the broker URL defaults to `connect.elevenmessenger.com` |
| 54 | (`LCHAT_CONNECT_BROKER_URL` overrides for a self-hosted broker). |
| 55 | |
| 56 | The sidecar side is ported to Go (`internal/bot/connect.go` + `dnsimple.go`), so |
| 57 | `lchat bot run` handles `connect.op`/`connect.oauth` with no Node β same env |
| 58 | (`LCHAT_CONNECT_BROKER_URL`/`_TOKEN`, `LCHAT_DNSIMPLE_BASE`), same wire shapes. |
| 59 | `agent/dnsimple.mjs` + `runtime.mjs` remain the reference. |
| 60 | |
| 61 | | Compromised | Holds | Usable to an attacker? | |
| 62 | |---|---|---| |
| 63 | | Redirect relay (public endpoint) | nothing | single-use codes only β **no** | |
| 64 | | Messenger instance | opaque ciphertext blobs | **no** | |
| 65 | | Exchange broker | `client_secret` + in-flight tokens | secret is *app-identity* (impersonation, not account access); tokens in-flight only, never stored | |
| 66 | | Per-instance sidecar | a user token transiently (to seal it) | scoped to that instance | |
| 67 | |
| 68 | The `client_secret` is *app-identity auth* β same tier as the admin token / APNs |
| 69 | `.p8`. A leak enables impersonation in an OAuth prompt, not access to anyone's |
| 70 | account (DNSimple still requires the user's consent to mint a token). |
| 71 | |
| 72 | ### Self-hosting: on-the-fly broker registration (planned, not built) |
| 73 | |
| 74 | Today the broker admin hand-mints a per-instance token (fine for our fleet, but a |
| 75 | barrier for a self-hoster pointing at our broker β they'd have to ask us). The |
| 76 | plan is **dynamic registration with a domain-control proof**: an instance POSTs |
| 77 | its origin to the broker; the broker verifies the instance actually serves at that |
| 78 | origin (ACME/HTTP-01-style β fetch a challenge from |
| 79 | `https://<origin>/.well-known/lchat-connect`); on success it auto-allowlists the |
| 80 | origin and issues a token. No human in the loop, and safe: **you can only register |
| 81 | an origin you control**, so no open-redirect and no code hijacking. (A self-hoster |
| 82 | running their *own* broker needs nothing from us.) |
| 83 | |
| 84 | ### Connected services as installed apps (prototyped) |
| 85 | |
| 86 | Once connected, a service *installs as its own app*. The DNSimple app |
| 87 | (`web/static/apps/dnsimple.js`) is the working proof-of-concept: it appears in |
| 88 | New chat **only after** you've linked DNSimple (a new `requiresService` gate on |
| 89 | the app spec) and opens as its own thread showing a **table** of your domains |
| 90 | (name β status, expiry, renewal price, auto-renew) β filterable, keyboard-navigable |
| 91 | (β/β/Enter to open a domain on dnsimple.com), with a Refresh. It's a **takeover** |
| 92 | app: the table *is* the thread, with no chat view, so a fetch never echoes the |
| 93 | list into a chat bubble. (Conversational AI β "register this domain" β would |
| 94 | reintroduce a chat view later, once there's a bot to actually converse with.) |
| 95 | |
| 96 | It's a **client-side mini-app**, a "dynamic bot" β **no per-service sidecar**. |
| 97 | DNSimple's API has no browser CORS (confirmed β same as the token endpoint), so |
| 98 | the app can't call it directly. It **delegates each operation to the existing |
| 99 | per-instance sidecar via a time-boxed grant**: `ctx.host.connected.requestData` |
| 100 | seals the token to the bot for one op, the sidecar makes the call and answers |
| 101 | with a non-rendering `connect.result` (correlated by a request id), and the app |
| 102 | folds the result into state (which syncs across your devices). The bot **never |
| 103 | posts the result as a message** β it only returns the invisible `connect.result`, |
| 104 | so connected-service data never surfaces in the bot's DM. *Not* the broker: |
| 105 | proxying per-call there would make the shared broker see every user's token on |
| 106 | every call (breaking "shared infra leaks nothing"). On **native** there's no CORS |
| 107 | wall, so the app could later call the API directly. |
| 108 | |
| 109 | Known follow-ups from this POC: |
| 110 | - **No slash command.** The Interface view *is* the domain list (auto-loaded, with |
| 111 | a filter + Refresh + per-domain links to dnsimple.com), so the app registers no |
| 112 | `/list` β a verb would be redundant, and routing it through the bot only risks |
| 113 | the bot speaking. If context-local commands are wanted later, the generic |
| 114 | resolver (every app claims a plain verb, resolved by the thread you're in) is the |
| 115 | follow-up. |
| 116 | - **Per-bot "handles connect" capability flag** to replace `findBotThread`'s |
| 117 | name-ranking of Eleven/Claude. |
| 118 | - **Sidebar gating is one-directional.** `requiresService` hides the app from |
| 119 | New chat until connected, but a thread already created stays in the sidebar if |
| 120 | you later disconnect (the app then shows a "not available" state). |
| 121 | |
| 122 | Longer term these companion apps should be **installable and maintained outside |
| 123 | this repo** rather than baked in. |
| 124 | |
| 125 | ## The one split that unlocks it: storage vs. execution |
| 126 | |
| 127 | - **Storage** β where the credential rests, and in what form. |
| 128 | - **Execution** β which process actually calls DNSimple, and whether it needs the |
| 129 | credential *in the clear* at call time. |
| 130 | |
| 131 | The core rule only binds the **messenger instance server**: it may hold, at most, |
| 132 | an opaque encrypted-at-rest blob it cannot open. It says nothing about a |
| 133 | *different* trust domain (the user's browser, the bot sidecar) holding the |
| 134 | credential. So the whole design is: **keep the usable credential off the instance |
| 135 | server; decide which other domain holds it, and for how long.** |
| 136 | |
| 137 | ### The one hard truth |
| 138 | |
| 139 | To call DNSimple, *something* must hold the token in the clear at that instant. |
| 140 | So any scheme can bound **when the token is acquired** β it can never bound how |
| 141 | long a dishonest/compromised holder **retains** what it already saw. **You cannot |
| 142 | un-see plaintext.** Every guarantee below is "bounded blast radius," never |
| 143 | "zero-knowledge." |
| 144 | |
| 145 | ## Who sees what |
| 146 | |
| 147 | | Party | Sees | |
| 148 | |---|---| |
| 149 | | Messenger instance server | only an opaque ciphertext blob (the wrapped token) + wrapped grants β never the token, never a usable key | |
| 150 | | The user's browser | the token in the clear, only while the user is present (unwraps with their key / passkey) | |
| 151 | | The bot sidecar | the token in the clear **only during an open, time-boxed grant** β nothing between windows | |
| 152 | | Redirect bouncer (OAuth path only) | a single-use authorization `code` in transit β **useless without the PKCE `code_verifier`, which never leaves the browser** | |
| 153 | | Nobody | a client_secret (there is none β see OAuth below), or a standing plaintext token on the instance server | |
| 154 | |
| 155 | ## Token custody β temporal, client-encrypted |
| 156 | |
| 157 | The token never rests in usable form on any server. It's stored client-encrypted |
| 158 | and only ever decrypted with user presence; the bot receives short-lived, |
| 159 | re-wrapped **grants**, never the long-lived secret at rest. |
| 160 | |
| 161 | 1. Browser generates a random `DEK`; `C = AES-GCM(DEK, token)` β stored |
| 162 | server-side as an opaque blob (rule-safe). |
| 163 | 2. `DEK` is wrapped to the **user's** key (or a passkey-derived KEK via the home |
| 164 | server) and stored β only a client with user presence can unwrap it. |
| 165 | 3. On "sign in," the browser unwraps `DEK` and **re-wraps it to the bot's public |
| 166 | key with an expiry**: `G = ECIES(bot_pub, DEK β exp)`, stored server-side |
| 167 | (opaque). This reuses the exact ECDH-P256 + ECIES wrapping already in |
| 168 | `crypto.js` β the bot is already a member with a keypair. |
| 169 | 4. The bot unwraps `DEK`, decrypts `C`, acts; it drops `DEK`/token at `exp` and |
| 170 | refuses to act without a fresh `G`. |
| 171 | 5. Every N hours the user re-authenticates β fresh `G`, new `exp`. |
| 172 | |
| 173 | `exp` is enforced by the bot honouring it: a compromise *during* a window gets the |
| 174 | token; a compromise *between* windows finds only ciphertext. The gain over a |
| 175 | static env token is real β exposure is bounded to *currently-open windows* β but |
| 176 | it is not the E2E "compromise = nothing" guarantee. Say so honestly wherever this |
| 177 | surfaces to users. |
| 178 | |
| 179 | ### The home server is the natural store + re-auth clock |
| 180 | |
| 181 | "Sign in every N hours" **is a passkey ceremony**, and the home server already has |
| 182 | all of it: passkey PRF β HKDF β KEK (requires user presence, never leaves the |
| 183 | client), ceremony proofs, per-passkey DEK wrapping. Its README already reserves |
| 184 | the blob for "later, credentials." So home holds the encrypted token; the passkey |
| 185 | ceremony *is* the periodic re-auth; the browser re-issues the bot's grant. Home |
| 186 | stays a **vault, never the actor** β that keeps its anonymity and content-blind |
| 187 | invariants intact. |
| 188 | |
| 189 | ## Interactive vs. autonomous β often the whole problem dissolves |
| 190 | |
| 191 | - **Interactive commands** (`/register domain.com` β a human typed it, so a |
| 192 | browser with the key is present): issue a **one-shot, single-operation grant** |
| 193 | (or call via a dumb proxy) just for that command. No standing window, minimal |
| 194 | exposure. This covers the headline UX. |
| 195 | - **Autonomous tasks** (renew a domain at midnight, watch DNS while you sleep): |
| 196 | these genuinely need standing access β the temporal window above, tuned to the |
| 197 | feature's tolerance. |
| 198 | |
| 199 | ## The OAuth question: no shared secret store needed |
| 200 | |
| 201 | DNSimple's OAuth supports **public clients with PKCE** β `client_secret` is |
| 202 | *"Required for confidential clients. Ignored for public clients."* Register Eleven |
| 203 | Messenger as a **public client** and: |
| 204 | |
| 205 | - **`client_secret`: does not exist.** Nothing to centralise, nothing to protect. |
| 206 | The browser generates a per-login `code_verifier`, sends `code_challenge`, and |
| 207 | proves it at token exchange. |
| 208 | - **`client_id`: not a secret.** It rides in the authorization URL in the clear β |
| 209 | plain config (bake it in, or serve from a trivial endpoint). |
| 210 | |
| 211 | The *only* thing OAuth pushes toward a shared component is the **`redirect_uri`**: |
| 212 | DNSimple requires it pre-registered (exact URI or subdirectory; OAuth apps are |
| 213 | registered by hand, no mint-per-instance API), and you can't pre-register every |
| 214 | subdomain. The seamless answer is **one once-registered redirect origin + a |
| 215 | stateless redirect bouncer**: the instance starts the flow with a `state` |
| 216 | identifying itself; DNSimple redirects to the shared callback; the bouncer bounces |
| 217 | the `code` back to the originating instance, where the **browser** does the PKCE |
| 218 | exchange. |
| 219 | |
| 220 | That bouncer **holds no secret and never sees a token.** It sees a single-use |
| 221 | `code` in transit β useless without the `code_verifier` that never leaves the |
| 222 | browser. It's a dumb relay (closer to a DNS record than a vault); it can live |
| 223 | anywhere and must **not** be the home server (which is deliberately |
| 224 | instance-decoupled and anonymous). Native apps (iOS/Mac) can use a loopback / |
| 225 | custom-scheme redirect and skip the bouncer entirely, like DNSimple's own CLI. |
| 226 | |
| 227 | ## Two paths, both without a secret or token vault |
| 228 | |
| 229 | 1. **Paste a scoped PAT (recommended v1).** DNSimple account/user tokens are |
| 230 | **scopable** to specific domains/zones + resource types (registrar, zones, β¦) β |
| 231 | least-privilege by construction. Captured in the browser, dropped straight into |
| 232 | the temporal scheme. **Zero shared infrastructure.** |
| 233 | 2. **OAuth PKCE public client (v2, UX upgrade).** User clicks Authorize instead of |
| 234 | pasting. Needs only the public `client_id` + the stateless bouncer (web only; |
| 235 | native skips it). Resulting token enters the same temporal scheme. |
| 236 | |
| 237 | ## Honest costs / open decisions |
| 238 | |
| 239 | - **Window length is a trade-off.** Short = less exposure but autonomous features |
| 240 | stop if the user doesn't re-auth (a domain could lapse β bad); long = keeps |
| 241 | working unattended but widens the window. **Warn before a window closes.** |
| 242 | - **Expiry β revocation.** Closing a window doesn't undo a leak that already |
| 243 | happened inside it β "Disconnect" must **rotate/revoke the token provider-side**, |
| 244 | not just delete the blob. |
| 245 | - **Least privilege.** Prefer the narrowest token the provider offers (DNSimple |
| 246 | scoped tokens); a broad registrar token is domain-takeover if it leaks mid-window. |
| 247 | - **Authz for shared credentials.** A group-shared account means "who may run |
| 248 | `/register`" is a policy the bot must enforce β the admin token authorises roster |
| 249 | management, not spending money on domains. New surface to design. |
| 250 | - **Confirmation.** `/register` spends money and mutates the world; per CLAUDE.md a |
| 251 | command answers in place β irreversible actions confirm via an **action chip**, |
| 252 | never silent execution. |
| 253 | |
| 254 | ## Command namespacing (why a connected service is an *app*) |
| 255 | |
| 256 | A connected service's commands want generic verbs β `/list` should list *your |
| 257 | domains* inside the DNSimple app. But today command registration is a single |
| 258 | global map (`apps.js` `commandOwner`, last-write-wins): the moment two apps want |
| 259 | `/list`, the second silently clobbers the first. Convention (every app uses its |
| 260 | own name: `/todo`, `/poll`) has hidden this; generic verbs break it. |
| 261 | |
| 262 | The fix fits the architecture we already have: **a thread is owned by an app** |
| 263 | (takeover) or **has a companion app**. That's the scope. Commands resolve |
| 264 | **context-local first + a namespaced alias**: |
| 265 | |
| 266 | - Inside the DNSimple app: bare `/list` β DNSimple's list. No *global* `/list` |
| 267 | exists, so no other app can clobber it β each app owns its own verbs. |
| 268 | - From any chat: `/dnsimple list` (the namespaced alias, Slack-familiar β `/jira |
| 269 | create`) always resolves unambiguously. |
| 270 | - Only a small, explicit set stays global: bot/system commands (`/help`, |
| 271 | `/theme`). |
| 272 | |
| 273 | This is why a connected service is a **first-class app** (a companion view on the |
| 274 | bot thread, or its own app-thread), not just a bot you DM: the app owns the |
| 275 | commands and the rendering (a place to *show* your domains); the bot stays the |
| 276 | *actor* that holds the grant and calls the API. |
| 277 | |
| 278 | Implementing this means moving command resolution off the global map onto |
| 279 | per-app tables keyed by the current context (`parseCommand`/`runCommand`/the `/` |
| 280 | typeahead), and migrating the existing apps β a focused change to the core |
| 281 | command system, tracked as its own follow-up. **Interim (this PR):** `/list` is |
| 282 | registered globally and DNSimple owns it because nothing else claims it; the |
| 283 | follow-up scopes it and adds the `/dnsimple list` alias. |
| 284 | |
| 285 | ## Phasing |
| 286 | |
| 287 | Ship **v1 on scoped PATs** β no shared infra, least-privilege, and it forces the |
| 288 | temporal-storage + bot-grant mechanics to be right. Layer **OAuth PKCE** on later |
| 289 | purely as UX: one public OAuth app + the stateless bouncer for web. At no point is |
| 290 | there a server holding a `client_secret` or a pile of user tokens. |
| 291 | |
| 292 | --- |
| 293 | |
| 294 | Sources (DNSimple specifics, verified 2026-07): |
| 295 | [OAuth API](https://developer.dnsimple.com/v2/oauth/) Β· |
| 296 | [CLI browser login (PKCE + loopback)](https://blog.dnsimple.com/2026/06/dnsimple-cli-browser-login/) Β· |
| 297 | [Scoped access tokens](https://blog.dnsimple.com/2023/11/scoped-access-tokens/) Β· |
| 298 | [API access token](https://support.dnsimple.com/articles/api-access-token/) |
| 299 | |