eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Connecting third-party services (`/register`, and friends)
2
3Status: **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
6OAuth API. **The plan evolved during implementation** β€” DNSimple turned out to be
7confidential-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
20A **seamless, out-of-the-box** "Connect a service" experience on a *fresh*
21instance β€” a member clicks Connect (or pastes a token), and afterwards a command
22like `/register domain.com` just works β€” **without** weakening the load-bearing
23rule (`CLAUDE.md`): if the messenger server is compromised, the attacker gets
24nothing 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
28box forever. We want connect-through-the-app as a first-class flow.
29
30## What shipped: DNSimple is confidential-only β†’ the connect broker
31
32Implementation reality overrode the PKCE plan below: **DNSimple offers no
33public/PKCE client** β€” every OAuth app is *confidential*, with a `client_secret`.
34So 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
36the push relay.
37
38The **connect broker** (`broker/`, deployed as `connect.elevenmessenger.com`)
39holds 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
51Instances fetch the public params (`client_id`, `authorize_url`, `redirect_uri`)
52from the broker's `GET /config`, so **a stock instance needs no DNSimple config at
53all**; the broker URL defaults to `connect.elevenmessenger.com`
54(`LCHAT_CONNECT_BROKER_URL` overrides for a self-hosted broker).
55
56The 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
68The `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
70account (DNSimple still requires the user's consent to mint a token).
71
72### Self-hosting: on-the-fly broker registration (planned, not built)
73
74Today the broker admin hand-mints a per-instance token (fine for our fleet, but a
75barrier for a self-hoster pointing at our broker β€” they'd have to ask us). The
76plan is **dynamic registration with a domain-control proof**: an instance POSTs
77its origin to the broker; the broker verifies the instance actually serves at that
78origin (ACME/HTTP-01-style β€” fetch a challenge from
79`https://<origin>/.well-known/lchat-connect`); on success it auto-allowlists the
80origin and issues a token. No human in the loop, and safe: **you can only register
81an origin you control**, so no open-redirect and no code hijacking. (A self-hoster
82running their *own* broker needs nothing from us.)
83
84### Connected services as installed apps (prototyped)
85
86Once 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
88New chat **only after** you've linked DNSimple (a new `requiresService` gate on
89the 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**
92app: the table *is* the thread, with no chat view, so a fetch never echoes the
93list into a chat bubble. (Conversational AI β€” "register this domain" β€” would
94reintroduce a chat view later, once there's a bot to actually converse with.)
95
96It's a **client-side mini-app**, a "dynamic bot" β€” **no per-service sidecar**.
97DNSimple's API has no browser CORS (confirmed β€” same as the token endpoint), so
98the app can't call it directly. It **delegates each operation to the existing
99per-instance sidecar via a time-boxed grant**: `ctx.host.connected.requestData`
100seals the token to the bot for one op, the sidecar makes the call and answers
101with a non-rendering `connect.result` (correlated by a request id), and the app
102folds the result into state (which syncs across your devices). The bot **never
103posts the result as a message** β€” it only returns the invisible `connect.result`,
104so connected-service data never surfaces in the bot's DM. *Not* the broker:
105proxying per-call there would make the shared broker see every user's token on
106every call (breaking "shared infra leaks nothing"). On **native** there's no CORS
107wall, so the app could later call the API directly.
108
109Known 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
122Longer term these companion apps should be **installable and maintained outside
123this 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
131The core rule only binds the **messenger instance server**: it may hold, at most,
132an 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
134credential. So the whole design is: **keep the usable credential off the instance
135server; decide which other domain holds it, and for how long.**
136
137### The one hard truth
138
139To call DNSimple, *something* must hold the token in the clear at that instant.
140So any scheme can bound **when the token is acquired** β€” it can never bound how
141long a dishonest/compromised holder **retains** what it already saw. **You cannot
142un-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
157The token never rests in usable form on any server. It's stored client-encrypted
158and only ever decrypted with user presence; the bot receives short-lived,
159re-wrapped **grants**, never the long-lived secret at rest.
160
1611. Browser generates a random `DEK`; `C = AES-GCM(DEK, token)` β†’ stored
162 server-side as an opaque blob (rule-safe).
1632. `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.
1653. 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.
1694. The bot unwraps `DEK`, decrypts `C`, acts; it drops `DEK`/token at `exp` and
170 refuses to act without a fresh `G`.
1715. 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
174token; a compromise *between* windows finds only ciphertext. The gain over a
175static env token is real β€” exposure is bounded to *currently-open windows* β€” but
176it is not the E2E "compromise = nothing" guarantee. Say so honestly wherever this
177surfaces 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
182all of it: passkey PRF β†’ HKDF β†’ KEK (requires user presence, never leaves the
183client), ceremony proofs, per-passkey DEK wrapping. Its README already reserves
184the blob for "later, credentials." So home holds the encrypted token; the passkey
185ceremony *is* the periodic re-auth; the browser re-issues the bot's grant. Home
186stays a **vault, never the actor** β€” that keeps its anonymity and content-blind
187invariants 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
201DNSimple's OAuth supports **public clients with PKCE** β€” `client_secret` is
202*"Required for confidential clients. Ignored for public clients."* Register Eleven
203Messenger 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
211The *only* thing OAuth pushes toward a shared component is the **`redirect_uri`**:
212DNSimple requires it pre-registered (exact URI or subdirectory; OAuth apps are
213registered by hand, no mint-per-instance API), and you can't pre-register every
214subdomain. The seamless answer is **one once-registered redirect origin + a
215stateless redirect bouncer**: the instance starts the flow with a `state`
216identifying itself; DNSimple redirects to the shared callback; the bouncer bounces
217the `code` back to the originating instance, where the **browser** does the PKCE
218exchange.
219
220That 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
222browser. It's a dumb relay (closer to a DNS record than a vault); it can live
223anywhere and must **not** be the home server (which is deliberately
224instance-decoupled and anonymous). Native apps (iOS/Mac) can use a loopback /
225custom-scheme redirect and skip the bouncer entirely, like DNSimple's own CLI.
226
227## Two paths, both without a secret or token vault
228
2291. **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.**
2332. **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
256A connected service's commands want generic verbs β€” `/list` should list *your
257domains* inside the DNSimple app. But today command registration is a single
258global 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
260own name: `/todo`, `/poll`) has hidden this; generic verbs break it.
261
262The 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
273This is why a connected service is a **first-class app** (a companion view on the
274bot thread, or its own app-thread), not just a bot you DM: the app owns the
275commands and the rendering (a place to *show* your domains); the bot stays the
276*actor* that holds the grant and calls the API.
277
278Implementing this means moving command resolution off the global map onto
279per-app tables keyed by the current context (`parseCommand`/`runCommand`/the `/`
280typeahead), and migrating the existing apps β€” a focused change to the core
281command system, tracked as its own follow-up. **Interim (this PR):** `/list` is
282registered globally and DNSimple owns it because nothing else claims it; the
283follow-up scopes it and adds the `/dnsimple list` alias.
284
285## Phasing
286
287Ship **v1 on scoped PATs** β€” no shared infra, least-privilege, and it forces the
288temporal-storage + bot-grant mechanics to be right. Layer **OAuth PKCE** on later
289purely as UX: one public OAuth app + the stateless bouncer for web. At no point is
290there a server holding a `client_secret` or a pile of user tokens.
291
292---
293
294Sources (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