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>
14 files changed,
+864
−2
agent/dnsimple.mjs+77 −0agent/runtime.mjs+48 −1changelog/2026-07-05-connect-services.md+9 −0connected.go+112 −0connected_test.go+83 −0db.go+67 −0docs/connected-services.md+192 −0main.go+8 −0package.json+1 −1test/connect.test.mjs+92 −0web/static/apps.js+11 −0web/static/apps/connect.js+65 −0web/static/crypto.js+33 −0web/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. |