eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

πŸ€– 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 Β· CLI browser login (PKCE + loopback) Β· Scoped access tokens Β· API access token