eleven / messenger Public

Connected services: DNSimple prototype + design note (#192)

* connected services: prototype (DNSimple) + design note

A first, end-to-end-shaped slice of "connect a third-party service and let a
bot act on it", preserving the E2E rule: the messenger server only ever holds
opaque ciphertext, and no usable credential rests anywhere.

- docs/connected-services.md — the trust model: storage vs execution, temporal
  client-encrypted custody, and why PKCE means no OAuth secret broker.
- crypto: sealForPub/openSealed — wrapThreadKey's sibling for JSON, so an
  expiry can be bound *inside* a capability grant sealed to a bot's pubkey.
- server: connected_services table + token-gated /api/connected endpoints that
  store one opaque, client-encrypted blob per (member, service) and never read it.
- web: a `connect` mini-app (/connect, /register) + a narrow shell.js host
  bridge that does the privileged crypto (seal a credential to yourself; mint a
  short-lived grant sealed to the chat's bot).
- agent: dnsimple.mjs (read-only availability check as the live call; the paid
  registration is left behind a confirm step) + a connect.op handler that opens
  the grant, acts once, and drops the token.

Prototype scope: paste-a-scoped-PAT only (no OAuth/PKCE yet); the live DNSimple
call is the read-only availability check; web client only (the native embed
holds no private key). Tests: Go store round-trip + per-member isolation + auth;
node seal/open + DNSimple client against a mock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* connect: ship read-only /list; record command-namespacing decision

Swap /register → /list (list your domains) — read-only, no confirm needed, a
safer first command. Agent gains listDomains(); the connect app's /list asks the
chat's bot to fetch and post your domains.

Also record the command-namespacing decision in docs/connected-services.md:
generic verbs like /list must be context-local (owned by the app you're in) with
a /dnsimple list alias, not globally clobberable. The context-scoped resolver is
a follow-up; interim, /list is global and DNSimple is its sole owner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell accccc840eee292f64ecd6cb7b19236f58bd2c37 parent fec9781
14 files changed, +864 −2
  • agent/dnsimple.mjs +77 −0
  • agent/runtime.mjs +48 −1
  • changelog/2026-07-05-connect-services.md +9 −0
  • connected.go +112 −0
  • connected_test.go +83 −0
  • db.go +67 −0
  • docs/connected-services.md +192 −0
  • main.go +8 −0
  • package.json +1 −1
  • test/connect.test.mjs +92 −0
  • web/static/apps.js +11 −0
  • web/static/apps/connect.js +65 −0
  • web/static/crypto.js +33 −0
  • web/static/shell.js +66 −0
diff --git a/agent/dnsimple.mjs b/agent/dnsimple.mjs
new file mode 100644
index 0000000..97d7be5
--- /dev/null
+++ b/agent/dnsimple.mjs
@@ -0,0 +1,77 @@
+// Minimal DNSimple client for the connected-services prototype
+// (docs/connected-services.md). Kept tiny and dependency-free so the only impure
+// part is the fetch — point it at a fake with LCHAT_DNSIMPLE_BASE in tests.
+//
+// The token this receives is a *scoped* DNSimple access token, handed to the bot
+// inside a time-boxed, sealed capability grant — never stored on this host. We
+// hold it only for the duration of one call and drop it.
+const BASE = (process.env.LCHAT_DNSIMPLE_BASE || "https://api.dnsimple.com").replace(/\/$/, "");
+
+async function api(token, method, path) {
+ const r = await fetch(`${BASE}${path}`, {
+ method,
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
+ });
+ const text = await r.text();
+ let data = {};
+ try {
+ data = text ? JSON.parse(text) : {};
+ } catch {}
+ if (!r.ok) throw new Error(data && data.message ? data.message : `DNSimple ${r.status}`);
+ return data;
+}
+
+// whoami resolves the single account the token acts for. An account-scoped token
+// returns `{ data: { account: { id } } }`; a user token that spans several
+// accounts returns a user instead — we refuse it, since registrar calls are
+// account-scoped and we won't guess which account to spend from.
+export async function whoami(token) {
+ const d = await api(token, "GET", "/v2/whoami");
+ const id = d && d.data && d.data.account && d.data.account.id;
+ if (!id) throw new Error("that token isn't scoped to a single account — create an account access token");
+ return id;
+}
+
+// listDomains returns the domain names on the token's account — the read-only
+// call `/list` makes. GET /v2/{account}/domains -> { data: [ { name, ... } ] }.
+export async function listDomains(token) {
+ const account = await whoami(token);
+ const d = await api(token, "GET", `/v2/${account}/domains?per_page=100`);
+ return ((d && d.data) || []).map((r) => r && r.name).filter(Boolean);
+}
+
+const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/i;
+
+// checkAvailability is the read-only call `/register` makes first: is the domain
+// free to register, and is it premium-priced? No money moves.
+// GET /v2/{account}/registrar/domains/{domain}/check
+export async function checkAvailability(domain, token) {
+ if (!DOMAIN_RE.test(domain || "")) throw new Error(`"${domain}" doesn't look like a domain name`);
+ const account = await whoami(token);
+ const d = await api(token, "GET", `/v2/${account}/registrar/domains/${encodeURIComponent(domain.toLowerCase())}/check`);
+ const info = (d && d.data) || {};
+ return { domain: info.domain || domain, available: !!info.available, premium: !!info.premium };
+}
+
+// registerDomain is the money-spending, irreversible step. It is deliberately
+// NOT called by the availability check — a real registration is confirmed by the
+// member tapping the "Register" chip (see docs/connected-services.md: irreversible
+// actions confirm in place, never as a command side effect). Wired here so the
+// shape is visible; the runtime does not auto-invoke it.
+// POST /v2/{account}/registrar/domains/{domain}/registrations { registrant_id }
+export async function registerDomain(domain, token, { registrantId } = {}) {
+ if (!DOMAIN_RE.test(domain || "")) throw new Error(`"${domain}" doesn't look like a domain name`);
+ if (!registrantId) throw new Error("registration needs a registrant contact id (registrantId)");
+ const account = await whoami(token);
+ const r = await fetch(
+ `${BASE}/v2/${account}/registrar/domains/${encodeURIComponent(domain.toLowerCase())}/registrations`,
+ {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
+ body: JSON.stringify({ registrant_id: registrantId }),
+ },
+ );
+ const d = await r.json().catch(() => ({}));
+ if (!r.ok) throw new Error(d && d.message ? d.message : `DNSimple ${r.status}`);
+ return { id: d && d.data && d.data.id, state: (d && d.data && d.data.state) || "requested" };
+}
diff --git a/agent/runtime.mjs b/agent/runtime.mjs
index 204e20e..5f1b1f0 100644
--- a/agent/runtime.mjs
+++ b/agent/runtime.mjs
@@ -7,10 +7,11 @@
import fs from "node:fs";
import Anthropic from "@anthropic-ai/sdk";
import WebSocket from "ws";
-import { importKey, encrypt, decrypt, unwrapThreadKey } from "../web/static/crypto.js";
+import { importKey, encrypt, decrypt, unwrapThreadKey, openSealed } from "../web/static/crypto.js";
import { BASE, WS_BASE, MODEL, CLASSIFIER_MODEL, APP_NAME, AI_ENABLED } from "./config.mjs";
import { actionsFromToolUse } from "./actions.mjs";
import { runCommand } from "./commands.mjs";
+import { listDomains } from "./dnsimple.mjs";
const log = (m) => {
try {
@@ -294,6 +295,46 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t
send({ type: "msg", thread_id: threadId, blob: await encrypt(entry.key, payload) });
}
+ // A connected-service capability op (docs/connected-services.md). The member
+ // sealed a time-boxed grant to THIS bot's public key and asked us to act with
+ // it. We open it with our own private key; a grant sealed to a different bot
+ // won't open, so we stay silent (it isn't ours). The third-party token lives
+ // only for this one call — we never persist it, and the window is one op wide.
+ async function handleConnect(threadId, entry, p) {
+ let grant;
+ try {
+ grant = await openSealed(p.grant || "", session.userPriv);
+ } catch {
+ return; // not sealed to us
+ }
+ if (!grant || (grant.exp && grant.exp < Date.now())) {
+ await postReply(threadId, entry, "That authorization has expired — run the command again to re-issue it.");
+ return;
+ }
+ if (grant.service !== "dnsimple") {
+ await postReply(threadId, entry, `I can't act on "${grant.service}" yet.`);
+ return;
+ }
+ try {
+ if (p.op !== "list") {
+ await postReply(threadId, entry, `I don't know the "${p.op}" action.`);
+ return;
+ }
+ const domains = await listDomains(grant.token);
+ await postReply(
+ threadId,
+ entry,
+ domains.length
+ ? `Your DNSimple domains (${domains.length}):\n${domains.map((d) => `• ${d}`).join("\n")}`
+ : "That DNSimple account has no domains yet.",
+ );
+ } catch (e) {
+ await postReply(threadId, entry, `DNSimple error: ${e.message}`);
+ } finally {
+ grant.token = null; // drop the token
+ }
+ }
+
// Strip a leading "@Name" so a group command ("@Eleven /help") parses; a bare
// command in a DM passes through unchanged.
function stripMention(text) {
@@ -382,6 +423,12 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t
}
if (p.name === name) return; // our own message, echoed back
if (agentNames.has(p.name)) return; // bots never talk to bots (see loadAgents)
+ // A direct capability invocation (a sealed connected-service grant), not a
+ // chat trigger — handle it whether or not we have a model brain.
+ if (p.type === "connect.op") {
+ await handleConnect(threadId, entry, p);
+ return;
+ }
const trigger = replyTrigger(entry.kind, p.text);
if (!trigger) return;
// The bare-name gate needs the model; with no AI there's nothing to classify.
diff --git a/changelog/2026-07-05-connect-services.md b/changelog/2026-07-05-connect-services.md
new file mode 100644
index 0000000..0e5dc2a
--- /dev/null
+++ b/changelog/2026-07-05-connect-services.md
@@ -0,0 +1,9 @@
+You can now connect an outside service to a chat and let a bot act on it — the
+first, experimental step toward things like managing domains from a message.
+Type `/connect dnsimple <your access token>` to link a DNSimple account: your
+token is encrypted on your device before it's stored, so the server never sees
+it. Then `/list` asks the bot in your chat to fetch your domains and post them
+back. When it acts, the bot only ever gets a short-lived, sealed copy of your
+token — never the long-lived one — and nothing usable is kept on the server.
+More services, more commands, and a "buy this domain" step (with a confirm) will
+follow.
diff --git a/connected.go b/connected.go
new file mode 100644
index 0000000..49d31d1
--- /dev/null
+++ b/connected.go
@@ -0,0 +1,112 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "regexp"
+)
+
+// Connected third-party services (docs/connected-services.md).
+//
+// The server stores one opaque, client-encrypted credential blob per (member,
+// service) and never reads it — the same trust boundary as instance_keys. A
+// member seals their credential to their OWN key before storing it; actually
+// *using* it is a separate, time-boxed grant the member seals to a bot. Nothing
+// usable ever rests on the server, so a server compromise yields ciphertext.
+const maxConnectedBytes = 16 * 1024
+
+// Service names are our own keys, never interpolated into an outbound request,
+// but keep them to a tidy slug regardless.
+var serviceRE = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
+
+// GET /api/connected?token= -> {"services": ["dnsimple", ...]}
+func (s *Server) listConnected(w http.ResponseWriter, r *http.Request) {
+ token := r.URL.Query().Get("token")
+ if !s.tokenOK(token) {
+ httpError(w, http.StatusForbidden, "invalid invite")
+ return
+ }
+ userID, _ := s.db.deviceTokenUser(token)
+ services, err := s.db.listConnectedServices(userID)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ if services == nil {
+ services = []string{}
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"services": services})
+}
+
+// GET /api/connected/{service}?token= -> {"service","blob","connected"}
+func (s *Server) getConnected(w http.ResponseWriter, r *http.Request) {
+ token := r.URL.Query().Get("token")
+ if !s.tokenOK(token) {
+ httpError(w, http.StatusForbidden, "invalid invite")
+ return
+ }
+ service := r.PathValue("service")
+ if !serviceRE.MatchString(service) {
+ httpError(w, http.StatusBadRequest, "bad service")
+ return
+ }
+ userID, _ := s.db.deviceTokenUser(token)
+ blob, ok, err := s.db.getConnectedService(userID, service)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"service": service, "blob": blob, "connected": ok})
+}
+
+// PUT /api/connected/{service} body {"token","blob"} -> {"ok":true}
+func (s *Server) putConnected(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, maxConnectedBytes+4096)
+ var in struct {
+ Token string `json:"token"`
+ Blob string `json:"blob"`
+ }
+ if json.NewDecoder(r.Body).Decode(&in) != nil {
+ httpError(w, http.StatusBadRequest, "bad json")
+ return
+ }
+ if !s.tokenOK(in.Token) {
+ httpError(w, http.StatusForbidden, "invalid invite")
+ return
+ }
+ service := r.PathValue("service")
+ if !serviceRE.MatchString(service) {
+ httpError(w, http.StatusBadRequest, "bad service")
+ return
+ }
+ if in.Blob == "" || len(in.Blob) > maxConnectedBytes {
+ httpError(w, http.StatusBadRequest, "bad blob")
+ return
+ }
+ userID, _ := s.db.deviceTokenUser(in.Token)
+ if err := s.db.putConnectedService(userID, service, in.Blob); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// DELETE /api/connected/{service}?token= -> {"ok":true} (disconnect)
+func (s *Server) deleteConnected(w http.ResponseWriter, r *http.Request) {
+ token := r.URL.Query().Get("token")
+ if !s.tokenOK(token) {
+ httpError(w, http.StatusForbidden, "invalid invite")
+ return
+ }
+ service := r.PathValue("service")
+ if !serviceRE.MatchString(service) {
+ httpError(w, http.StatusBadRequest, "bad service")
+ return
+ }
+ userID, _ := s.db.deviceTokenUser(token)
+ if err := s.db.deleteConnectedService(userID, service); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
diff --git a/connected_test.go b/connected_test.go
new file mode 100644
index 0000000..474746c
--- /dev/null
+++ b/connected_test.go
@@ -0,0 +1,83 @@
+package main
+
+import (
+ "net/http"
+ "testing"
+)
+
+// The connected-services store (connected.go) persists an opaque, client-encrypted
+// blob per (member, service), token-gated, and never reads it. Round-trip + the
+// per-member isolation that keeps one member's credential blob out of another's.
+func TestConnectedServicesStore(t *testing.T) {
+ ts, srv := newTestServer(t)
+ tokA, _, _ := joinedMember(t, srv, "Ann")
+ tokB, _, _ := joinedMember(t, srv, "Bo")
+
+ blob := "sealed-ciphertext-blob"
+ resp := doReq(t, "PUT", ts.URL+"/api/connected/dnsimple", `{"token":"`+tokA+`","blob":"`+blob+`"}`)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("PUT store: got %d", resp.StatusCode)
+ }
+ resp.Body.Close()
+
+ // Ann reads her blob back verbatim.
+ var got struct {
+ Service string `json:"service"`
+ Blob string `json:"blob"`
+ Connected bool `json:"connected"`
+ }
+ resp = doReq(t, "GET", ts.URL+"/api/connected/dnsimple?token="+tokA, "")
+ decodeJSON(t, resp, &got)
+ if !got.Connected || got.Blob != blob {
+ t.Fatalf("GET after store: connected=%v blob=%q", got.Connected, got.Blob)
+ }
+
+ // It's listed.
+ var list struct {
+ Services []string `json:"services"`
+ }
+ resp = doReq(t, "GET", ts.URL+"/api/connected?token="+tokA, "")
+ decodeJSON(t, resp, &list)
+ if len(list.Services) != 1 || list.Services[0] != "dnsimple" {
+ t.Fatalf("list: %v", list.Services)
+ }
+
+ // Per-member isolation: Bo sees nothing for the same service.
+ var bo struct {
+ Connected bool `json:"connected"`
+ }
+ resp = doReq(t, "GET", ts.URL+"/api/connected/dnsimple?token="+tokB, "")
+ decodeJSON(t, resp, &bo)
+ if bo.Connected {
+ t.Fatalf("member isolation broken: Bo can see Ann's credential")
+ }
+
+ // Disconnect removes it.
+ resp = doReq(t, "DELETE", ts.URL+"/api/connected/dnsimple?token="+tokA, "")
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("DELETE: %d", resp.StatusCode)
+ }
+ resp.Body.Close()
+ resp = doReq(t, "GET", ts.URL+"/api/connected/dnsimple?token="+tokA, "")
+ decodeJSON(t, resp, &got)
+ if got.Connected {
+ t.Fatalf("still connected after DELETE")
+ }
+}
+
+// Every connected-services endpoint is token-gated.
+func TestConnectedServicesAuth(t *testing.T) {
+ ts, _ := newTestServer(t)
+
+ resp := doReq(t, "GET", ts.URL+"/api/connected/dnsimple", "")
+ if resp.StatusCode != http.StatusForbidden {
+ t.Fatalf("GET no token: got %d want 403", resp.StatusCode)
+ }
+ resp.Body.Close()
+
+ resp = doReq(t, "PUT", ts.URL+"/api/connected/dnsimple", `{"token":"nope","blob":"x"}`)
+ if resp.StatusCode != http.StatusForbidden {
+ t.Fatalf("PUT bad token: got %d want 403", resp.StatusCode)
+ }
+ resp.Body.Close()
+}
diff --git a/db.go b/db.go
index f136012..6b202d6 100644
--- a/db.go
+++ b/db.go
@@ -260,6 +260,19 @@ CREATE TABLE IF NOT EXISTS instance_avatar (
data BLOB NOT NULL,
mime TEXT NOT NULL,
updated_at INTEGER NOT NULL
+);
+-- Connected third-party services (docs/connected-services.md): one opaque,
+-- client-encrypted credential blob per (member, service) — e.g. a scoped
+-- DNSimple token the member sealed to their OWN key. The server keys it by
+-- member + service and never reads it (same trust boundary as instance_keys).
+-- Actually *using* a credential is a separate, time-boxed grant the member seals
+-- to a bot; nothing usable rests here.
+CREATE TABLE IF NOT EXISTS connected_services (
+ user_id TEXT NOT NULL,
+ service TEXT NOT NULL,
+ blob TEXT NOT NULL,
+ updated_at INTEGER NOT NULL,
+ PRIMARY KEY (user_id, service)
);`)
if err != nil {
return err
@@ -1965,6 +1978,60 @@ func (db *DB) putThreadState(threadID, slot, blob string, expect int64) (int64,
return expect + 1, true, nil
}
+// ---- connected services (docs/connected-services.md) --------------------
+//
+// One opaque, client-encrypted credential blob per (member, service). The server
+// keys/indexes it but never reads it. Keyed by user_id like instance_keys.
+
+// putConnectedService upserts a member's blob for a service.
+func (db *DB) putConnectedService(userID, service, blob string) error {
+ _, err := db.sql.Exec(
+ "INSERT INTO connected_services(user_id, service, blob, updated_at) VALUES(?, ?, ?, ?) "+
+ "ON CONFLICT(user_id, service) DO UPDATE SET blob = excluded.blob, updated_at = excluded.updated_at",
+ userID, service, blob, time.Now().Unix(),
+ )
+ return err
+}
+
+// getConnectedService returns a member's blob for a service (ok=false if none).
+func (db *DB) getConnectedService(userID, service string) (string, bool, error) {
+ var blob string
+ err := db.sql.QueryRow(
+ "SELECT blob FROM connected_services WHERE user_id = ? AND service = ?", userID, service,
+ ).Scan(&blob)
+ if err == sql.ErrNoRows {
+ return "", false, nil
+ }
+ if err != nil {
+ return "", false, err
+ }
+ return blob, true, nil
+}
+
+// deleteConnectedService removes a member's connection to a service (disconnect).
+func (db *DB) deleteConnectedService(userID, service string) error {
+ _, err := db.sql.Exec("DELETE FROM connected_services WHERE user_id = ? AND service = ?", userID, service)
+ return err
+}
+
+// listConnectedServices returns the service names a member has connected.
+func (db *DB) listConnectedServices(userID string) ([]string, error) {
+ rows, err := db.sql.Query("SELECT service FROM connected_services WHERE user_id = ? ORDER BY service", userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []string
+ for rows.Next() {
+ var svc string
+ if err := rows.Scan(&svc); err != nil {
+ return nil, err
+ }
+ out = append(out, svc)
+ }
+ return out, rows.Err()
+}
+
// ---- reactions ----------------------------------------------------------
//
// A reaction is (message_id, token, emoji). The emoji is stored in the clear so
diff --git a/docs/connected-services.md b/docs/connected-services.md
new file mode 100644
index 0000000..ec4dbfa
--- /dev/null
+++ b/docs/connected-services.md
@@ -0,0 +1,192 @@
+# Connecting third-party services (`/register`, and friends)
+
+Status: **design note — nothing shipped.** This captures the trust model we
+settled on so it's decided before any code. DNSimple is the worked example; the
+shape is meant to generalise to any bearer-token / OAuth API.
+
+## 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.
+
+## 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/)
diff --git a/main.go b/main.go
index f0636d8..a65d152 100644
--- a/main.go
+++ b/main.go
@@ -304,6 +304,14 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("GET /api/threads/{id}/state", s.getThreadStateHandler)
mux.HandleFunc("PUT /api/threads/{id}/state", s.putThreadStateHandler)
+ // Connected third-party services (docs/connected-services.md): opaque,
+ // client-encrypted credential blobs keyed per member. Token-gated; the server
+ // never reads inside.
+ mux.HandleFunc("GET /api/connected", s.listConnected)
+ mux.HandleFunc("GET /api/connected/{service}", s.getConnected)
+ mux.HandleFunc("PUT /api/connected/{service}", s.putConnected)
+ mux.HandleFunc("DELETE /api/connected/{service}", s.deleteConnected)
+
// Encrypted attachments: bytes are E2E-encrypted in the browser; the server
// stores/serves opaque blobs only. Both ends are token-gated.
mux.HandleFunc("POST /api/upload", s.uploadBlob)
diff --git a/package.json b/package.json
index 31cc4e6..c98c741 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/apps.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node home/test/crypto.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/apps.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node home/test/crypto.test.mjs"
}
}
diff --git a/test/connect.test.mjs b/test/connect.test.mjs
new file mode 100644
index 0000000..41bd78d
--- /dev/null
+++ b/test/connect.test.mjs
@@ -0,0 +1,92 @@
+// Connected-services core (docs/connected-services.md): the capability-grant
+// crypto (sealForPub/openSealed) and the DNSimple client's read-only checks.
+import http from "node:http";
+import { generateUserKeypair, sealForPub, openSealed } from "../web/static/crypto.js";
+
+function assert(cond, msg) {
+ if (!cond) {
+ console.error("FAIL:", msg);
+ process.exit(1);
+ }
+}
+
+// A grant round-trips WITH its metadata, and only the bot it was sealed to can
+// open it — the whole basis for handing a bot a time-boxed capability.
+async function testGrant() {
+ const bot = await generateUserKeypair();
+ const other = await generateUserKeypair();
+ const grant = { service: "dnsimple", token: "dnsimple_scoped_abc", exp: 1893456000000 };
+
+ const sealed = await sealForPub(bot.publicKey, grant);
+ const opened = await openSealed(sealed, bot.privateKey);
+ assert(
+ opened.token === grant.token && opened.service === "dnsimple" && opened.exp === grant.exp,
+ "grant seals/opens with its metadata intact",
+ );
+
+ let threw = false;
+ try {
+ await openSealed(sealed, other.privateKey);
+ } catch {
+ threw = true;
+ }
+ assert(threw, "a grant sealed to one bot can't be opened by another key");
+}
+
+// A fake DNSimple so the bot's read-only availability check is exercised without
+// a real account (LCHAT_DNSIMPLE_BASE is read at import time, so import late).
+function startMock() {
+ const server = http.createServer((req, res) => {
+ const send = (obj, code = 200) => {
+ res.writeHead(code, { "content-type": "application/json" });
+ res.end(JSON.stringify(obj));
+ };
+ if (!/^Bearer scoped_/.test(req.headers.authorization || "")) return send({ message: "Authentication failed" }, 401);
+ if (req.url === "/v2/whoami") return send({ data: { account: { id: 4242 } } });
+ if (req.url.startsWith("/v2/4242/domains")) return send({ data: [{ name: "one.example" }, { name: "two.example" }] });
+ const m = req.url.match(/^\/v2\/4242\/registrar\/domains\/([^/]+)\/check$/);
+ if (m) {
+ const d = decodeURIComponent(m[1]);
+ return send({ data: { domain: d, available: d === "free.example", premium: false } });
+ }
+ send({ message: "not found" }, 404);
+ });
+ return new Promise((resolve) => server.listen(0, () => resolve(server)));
+}
+
+async function testDnsimple() {
+ const server = await startMock();
+ process.env.LCHAT_DNSIMPLE_BASE = `http://127.0.0.1:${server.address().port}`;
+ const { checkAvailability, listDomains } = await import("../agent/dnsimple.mjs");
+
+ // /list — the wired, read-only command.
+ const domains = await listDomains("scoped_tok");
+ assert(domains.length === 2 && domains.includes("one.example"), "listDomains returns the account's domain names");
+
+ const free = await checkAvailability("free.example", "scoped_tok");
+ assert(free.available === true, "an unregistered domain reports available");
+ const taken = await checkAvailability("taken.example", "scoped_tok");
+ assert(taken.available === false, "a registered domain reports taken");
+
+ let authThrew = false;
+ try {
+ await checkAvailability("free.example", "rejected");
+ } catch {
+ authThrew = true;
+ }
+ assert(authThrew, "a rejected token surfaces an error");
+
+ let badDomain = false;
+ try {
+ await checkAvailability("not a domain", "scoped_tok");
+ } catch {
+ badDomain = true;
+ }
+ assert(badDomain, "a malformed domain is refused before any network call");
+
+ server.close();
+}
+
+await testGrant();
+await testDnsimple();
+console.log("connect.test.mjs OK");
diff --git a/web/static/apps.js b/web/static/apps.js
index 28577b9..5e54b21 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -518,6 +518,14 @@ let openThreadFn = null;
export function setThreadOpener(fn) {
openThreadFn = fn;
}
+// Privileged host capabilities an app can't perform itself because they need the
+// session keypair, roster public keys, or fetch — all of which live in shell.js.
+// Deliberately narrow. Currently just connected third-party services
+// (docs/connected-services.md); shell.js injects the implementation.
+let hostBridge = null;
+export function setHostBridge(b) {
+ hostBridge = b;
+}
function makeCtx(threadId, app) {
return {
@@ -565,6 +573,9 @@ function makeCtx(threadId, app) {
// Navigate to this app's thread — for an action chip a command offers, so
// moving out of the current context is always the user's tap.
open: () => (openThreadFn ? openThreadFn(threadId) : undefined),
+ // Privileged host capabilities (session keypair / roster / fetch), injected
+ // by shell.js — narrow by design (see setHostBridge). null in the native embed.
+ host: hostBridge,
};
}
diff --git a/web/static/apps/connect.js b/web/static/apps/connect.js
new file mode 100644
index 0000000..ca54f95
--- /dev/null
+++ b/web/static/apps/connect.js
@@ -0,0 +1,65 @@
+// Connected services (prototype) — docs/connected-services.md.
+//
+// Two slash-commands that ride the mini-app command framework but delegate the
+// privileged crypto to the host (ctx.host.connected), since an app can't touch
+// the session keypair, roster, or fetch:
+// /connect <service> <token> — store a credential, client-encrypted and sealed
+// to you; the server only ever holds ciphertext.
+// /register <domain> — ask the chat's bot to act with it, via a
+// short-lived grant sealed to that bot. Your
+// long-lived token never reaches the bot or server.
+// Both answer in place (ctx.reply); the bot's result lands in your chat with it.
+import { defineApp } from "../apps.js";
+
+// Services we know how to connect. DNSimple is the worked example; each entry is
+// a paste-a-scoped-token flow (no OAuth broker needed — see the design note).
+const SERVICES = ["dnsimple"];
+
+const unavailable = (ctx) => ctx.reply({ text: "Connecting services isn't available here yet." });
+
+export const app = defineApp({
+ name: "connect",
+ title: "Connected services",
+ glyph: "🔌",
+ scope: "personal", // gets its own thread; here it just hosts the two commands
+ initialState: () => ({}),
+ events: {},
+ commands: {
+ "/connect": {
+ desc: "Connect a third-party service",
+ args: "<service> <token>",
+ run: async (args, ctx) => {
+ if (!ctx.host || !ctx.host.connected) return unavailable(ctx);
+ const sp = args.indexOf(" ");
+ const service = (sp === -1 ? args : args.slice(0, sp)).trim().toLowerCase();
+ const token = sp === -1 ? "" : args.slice(sp + 1).trim();
+ if (!SERVICES.includes(service))
+ return ctx.reply({ text: `I can connect: ${SERVICES.join(", ")}. Try /connect dnsimple <token>.` });
+ if (!token) return ctx.reply({ text: `Paste your ${service} access token: /connect ${service} <token>` });
+ const res = await ctx.host.connected.store(service, token);
+ ctx.reply({
+ text: res.ok
+ ? `Connected ${service}. Your token is encrypted to you — the server can't read it. Try /register <domain>.`
+ : `Couldn't connect ${service}: ${res.error}`,
+ });
+ },
+ },
+ // NOTE: `/list` is a generic verb. Until the context-scoped command resolver
+ // lands (docs/connected-services.md § Command namespacing), it's registered
+ // globally and DNSimple owns it because nothing else claims it. The follow-up
+ // makes it resolve to the app you're in, with `/dnsimple list` as the alias.
+ "/list": {
+ desc: "List your domains from your connected registrar",
+ args: "",
+ run: async (_args, ctx) => {
+ if (!ctx.host || !ctx.host.connected) return unavailable(ctx);
+ const res = await ctx.host.connected.request("dnsimple", "list");
+ ctx.reply({
+ text: res.ok
+ ? `Asked ${res.bot} for your domains — the list will land in your chat with them.`
+ : `Can't do that: ${res.error}`,
+ });
+ },
+ },
+ },
+});
diff --git a/web/static/crypto.js b/web/static/crypto.js
index 3f6d3c4..33da1b0 100644
--- a/web/static/crypto.js
+++ b/web/static/crypto.js
@@ -180,3 +180,36 @@ export async function unwrapThreadKey(wrappedB64, privKeyB64) {
const aes = await eciesKey(priv, ephPub);
return bytesToB64url(await decryptBytes(aes, buf.slice(65)));
}
+
+// ---- capability grants (see docs/connected-services.md) -----------------
+// sealForPub / openSealed are wrapThreadKey's sibling for arbitrary JSON: the
+// same ECIES-to-a-public-key wire shape (ephPub(65) || iv(12) || ciphertext),
+// but the sealed payload is a small object rather than a bare key. Binding
+// metadata *inside* the seal (e.g. an `exp` expiry) means the relaying server
+// can't extend or alter it — it only ever holds the opaque blob. Used to hand a
+// bot a time-boxed capability (a third-party token, or a data key) that only
+// that bot's private key can open.
+
+// sealForPub seals `obj` (JSON-serialisable) to a recipient's user public key.
+export async function sealForPub(recipientPubB64, obj) {
+ const recipient = await crypto.subtle.importKey("raw", b64urlToBytes(recipientPubB64), EC, false, []);
+ const eph = await crypto.subtle.generateKey(EC, true, ["deriveBits"]);
+ const aes = await eciesKey(eph.privateKey, recipient);
+ const body = await encryptBytes(aes, new TextEncoder().encode(JSON.stringify(obj))); // iv||ct
+ const ephPub = new Uint8Array(await crypto.subtle.exportKey("raw", eph.publicKey));
+ const out = new Uint8Array(ephPub.length + body.length);
+ out.set(ephPub, 0);
+ out.set(body, ephPub.length);
+ return bytesToB64url(out);
+}
+
+// openSealed opens a sealForPub blob with your user private key (base64url
+// pkcs8), returning the object. Throws if it wasn't sealed to your key.
+export async function openSealed(sealedB64, privKeyB64) {
+ const buf = b64urlToBytes(sealedB64);
+ const ephPub = await crypto.subtle.importKey("raw", buf.slice(0, 65), EC, false, []);
+ const priv = await crypto.subtle.importKey("pkcs8", b64urlToBytes(privKeyB64), EC, false, ["deriveBits"]);
+ const aes = await eciesKey(priv, ephPub);
+ const pt = await decryptBytes(aes, buf.slice(65));
+ return JSON.parse(new TextDecoder().decode(pt));
+}
diff --git a/web/static/shell.js b/web/static/shell.js
index 2dadcbf..9918e12 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -16,6 +16,8 @@ import {
generateUserKeypair,
wrapThreadKey,
unwrapThreadKey,
+ sealForPub,
+ openSealed,
randomSecret,
deriveInvite,
} from "/static/crypto.js";
@@ -47,6 +49,7 @@ import {
setDefaultBot,
setThreadHasBot,
setThreadOpener,
+ setHostBridge,
runCommand,
commandCatalog,
buildLocalNotice,
@@ -76,6 +79,7 @@ import "/static/apps/remind.js";
import "/static/apps/bookmarks.js";
import "/static/apps/poll.js";
import "/static/apps/board.js";
+import "/static/apps/connect.js";
import { setHost as setChatAppHost, setConnected as setChatAppConnected } from "/static/apps/chat.js";
import { parseInviteFragment, buildInviteFragment } from "/static/invite.js";
import { qrCanvas } from "/static/qrcode.js";
@@ -1773,6 +1777,68 @@ setEmitTransport((threadId, event) => {
});
});
+// Connected third-party services (docs/connected-services.md). The privileged
+// half of the connect app — the parts an app can't do itself because they need
+// the session keypair, roster public keys, and the socket. Two moves:
+// • store: seal a credential to your OWN key and PUT the opaque blob (the server
+// stores ciphertext it can't read);
+// • request: reopen it locally and hand a bot a SHORT-LIVED grant sealed to that
+// bot's public key. The bot never gets your long-lived token — only a copy
+// that self-expires, sealed so only it can open it.
+// Web-only: the native embed never holds userPriv, so both no-op there.
+setHostBridge({
+ connected: {
+ async store(service, secret) {
+ if (!session.userPriv) return { ok: false, error: "connecting services isn't supported in the native app yet" };
+ const me = roster.find((e) => e.user_id === myUserId);
+ if (!me || !me.public_key) return { ok: false, error: "your encryption key isn't ready yet" };
+ const blob = await sealForPub(me.public_key, { secret }); // sealed to you; only you can reopen
+ const r = await fetch(`/api/connected/${encodeURIComponent(service)}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, blob }),
+ });
+ return r.ok ? { ok: true } : { ok: false, error: `couldn't save (${r.status})` };
+ },
+ async request(service, op, arg, ttlMs = 120000) {
+ if (!session.userPriv) return { ok: false, error: "connecting services isn't supported in the native app yet" };
+ // Find a chat that has a bot in it (prefer a DM) — that's who does the work.
+ let target = null;
+ let bot = null;
+ for (const t of threads) {
+ const agent = (t.members || [])
+ .filter((id) => id !== myUserId)
+ .map((id) => roster.find((e) => e.user_id === id))
+ .find((e) => e && e.is_agent && e.public_key);
+ if (!agent) continue;
+ if (!target || (t.kind === "dm" && target.kind !== "dm")) {
+ target = t;
+ bot = agent;
+ }
+ if (t.kind === "dm") break;
+ }
+ if (!target || !bot) return { ok: false, error: "start a chat with a bot first — it does the work" };
+ const key = threadKeys[target.id];
+ if (!key || !ws || ws.readyState !== WebSocket.OPEN) return { ok: false, error: "not connected right now" };
+ const r = await fetch(`/api/connected/${encodeURIComponent(service)}?token=${encodeURIComponent(session.token)}`);
+ const got = r.ok ? await r.json() : {};
+ if (!got.connected || !got.blob) return { ok: false, error: `you haven't connected ${service} yet — try /connect ${service} <token>` };
+ let opened;
+ try {
+ opened = await openSealed(got.blob, session.userPriv);
+ } catch {
+ return { ok: false, error: "couldn't reopen your stored credential" };
+ }
+ const grant = await sealForPub(bot.public_key, { service, token: opened.secret, exp: Date.now() + ttlMs });
+ const payload = { type: "connect.op", service, op, domain: arg, grant, name: session.name, ts: Date.now() };
+ if (myUserId) payload.uid = myUserId;
+ const blob = await encrypt(key, payload);
+ ws.send(JSON.stringify({ type: "msg", thread_id: target.id, blob }));
+ return { ok: true, bot: bot.label || "the bot" };
+ },
+ },
+});
+
// Scheduling transport (ctx.schedule / ctx.unschedule): encrypt the event NOW —
// the server holds only ciphertext + (thread, time) — and let it deliver the
// blob into the thread at the due moment, where it arrives as a normal message.