eleven / messenger Public

DNSimple installs as a companion app once connected (#219)

* web: DNSimple installs as a companion app once connected

After you link DNSimple, it now installs as its own personal mini-app (gated on a
new `requiresService` field so it only appears in New chat once connected). It's
a COMPANION app: an Interface view lists your domains and a Chat view reads each
fetch back as a conversation with it.

No new sidecar / bot identity — a 'dynamic bot': the app delegates the API call to
the EXISTING per-instance sidecar. New `ctx.host.connected.requestData` seals the
token to the bot for one op, the sidecar answers with a non-rendering
`connect.result` (correlated by request id), and the app folds the result into
state (Interface + Chat update; list syncs across devices via the snapshot).

- agent/runtime.mjs: handleConnect answers structured connect.result when the
  caller mints an rid; legacy text reply (the /list command) otherwise — non-breaking.
- shell.js: requestData bridge + awaitConnectResult; New-chat gates requiresService
  apps on /api/connected.
- apps/dnsimple.js: the companion app. apps/README.md: documents requiresService.
- docs/connected-services.md: companion section marked prototyped + follow-ups.
- test/apps.test.mjs: reducer + companion + chat-projection tests.

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

* web: context-local /list + loud DNSimple failure states

- Remove the global /list from the connect app (it was a generic verb acting at a
  distance). DNSimple now owns /list and it's CONTEXT-LOCAL: it lists domains only
  when typed inside the DNSimple thread (new ctx.invokedHere), and elsewhere offers
  an 'Open DNSimple' chip instead of acting remotely.
- Make the DNSimple app's failure states loud (in the list area, not a muted note):
  a requestData error now reads 'Couldn't load your domains: <reason>', and the
  no-bridge case says to open the web app. This surfaces the native-app limitation
  (session.userPriv is null in the embed, so connecting services is web-only) that
  was silently leaving the list empty.
- apps.js: ctx.invokedHere; README documents it. docs: namespacing note updated.
- test: /list ownership + context-local gating.

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

* web: a mini-app render error can't white-screen the client

apps.js renderApp called app.render unguarded. Since a personal app's render can
BE the whole thread and is reached at boot (reopening your last-active thread), a
throw there took down the entire client with the boot catch's 'Something went
wrong' card. Contain it: log + show a localized, non-fatal message in the app's
own pane, so one buggy/experimental app (e.g. a new companion) degrades to its
own error instead of nuking boot.

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

* web: fix DNSimple render crash on a fresh app-thread (undefined state)

appStateFor returns undefined for an app-thread with no folded state yet, so
render's state.fetchedAt (in drawList + the auto-load check) threw 'undefined is
not an object'. Normalise state at the top of render, matching how todo/remind
guard (state && state.items). The new apps.js render guard contained it to the
pane rather than white-screening — this fixes the underlying crash.

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

* web,agent: DNSimple domains as a table; bot never speaks the list

- The bot's connected-service op now replies ONLY with a non-rendering
  connect.result (removed the postReply text path) — the domain list belongs to
  the DNSimple app, never a visible message in Eleven's/the bot's DM.
- listDomains returns structured rows {name, expires_on, auto_renew, state} so the
  app can render a real table (Domain | Expires | Auto-renew), sorted by name.
- dnsimple.js: reducer normalises domains to objects (tolerates the old string
  form), render draws a scrollable table, chat projection lists names.
- Tests updated for the object shape + back-compat normalisation.

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

* web: normalise DNSimple domains in render for old string-form snapshots

A snapshot folded by the pre-table version holds domains as strings and is seeded
straight into state (bypassing the reducer), so render's table code (d.name /
localeCompare) would crash on it. Normalise in render too, so existing threads
upgrade to the table without a refresh.

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

* test: update listDomains test for structured rows (table fields)

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

* web,agent: filter the DNSimple domains + link each to its dnsimple.com page

- listDomains now includes account_id per row so the app can deep-link.
- dnsimple app: a filter box above the table (live, case-insensitive name match,
  value kept across re-renders, 'no matches' state), and each domain name links to
  https://dnsimple.com/a/<account>/domains/<name> (new tab). Old rows without an
  account id fall back to plain text until the next refresh.

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

* web: drop DNSimple's /list command — the Interface view is the list

The Interface auto-loads the domains (with filter, links, Refresh), so a /list
verb is redundant — and routing it through the bot only risks the bot speaking.
Removes the command, the now-unused ctx.invokedHere primitive, its README row and
test, and updates the design-doc follow-up note.

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

* web,agent: DNSimple table — keyboard nav, status (hosted), and cost

- Keyboard nav: the filter box doubles as a typeahead — ↑/↓ move an active row,
  Enter opens it on dnsimple.com, Esc clears the filter; clicking a row makes it
  active. Focus lands on the filter when free (never steals from the composer).
- Status column: the domain's state (registered / hosted).
- Renewal column: the domain's renewal price. The domains list omits pricing, so
  listDomains looks it up per-domain (bounded concurrency, best-effort — a failed
  lookup just shows '—').

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

* web: DNSimple is takeover (no chat list echo); confirm before disconnect

- DNSimple drops chatMessage → takeover app: the domain TABLE is the whole thread,
  no Chat view, so a fetch never echoes the domain list into a chat bubble
  (the thing that read as 'the bot replying with the list'). AI conversation would
  reintroduce a chat view later, once there's a bot to talk to.
- Connected services: a fetch/disconnect no longer happens on a single click —
  Disconnect now asks for inline confirmation first (removes the stored token).
- Tests + design doc updated (companion → takeover).

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 87713b8ab69ecc1d0682c89742fb6888a04901a6 parent 8e20692
11 files changed, +599 −54
  • agent/dnsimple.mjs +39 −3
  • agent/runtime.mjs +18 −11
  • changelog/2026-07-05-dnsimple-app.md +8 −0
  • docs/connected-services.md +38 −16
  • test/apps.test.mjs +47 −0
  • test/connect.test.mjs +11 −2
  • web/static/apps.js +15 −1
  • web/static/apps/README.md +1 −0
  • web/static/apps/connect.js +26 −20
  • web/static/apps/dnsimple.js +325 −0
  • web/static/shell.js +71 −1
diff --git a/agent/dnsimple.mjs b/agent/dnsimple.mjs
index d3ff208..86ace00 100644
--- a/agent/dnsimple.mjs
+++ b/agent/dnsimple.mjs
@@ -51,12 +51,48 @@ export async function whoami(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, ... } ] }.
+// Run an async fn over items with a bounded number in flight — the domains list
+// doesn't include pricing, so we fetch it per-domain and don't want N sockets at
+// once (nor to trip DNSimple's rate limit).
+async function mapLimit(items, limit, fn) {
+ const queue = items.map((item, i) => [item, i]);
+ const worker = async () => {
+ for (;;) {
+ const next = queue.shift();
+ if (!next) return;
+ await fn(next[0], next[1]);
+ }
+ };
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
+}
+
+// listDomains returns structured rows for the DNSimple app's table. GET
+// /v2/{account}/domains gives name/state/expiry/auto_renew; the renewal price
+// isn't in that payload, so we look it up per-domain (best-effort, bounded
+// concurrency) — a failure just leaves that row's price undefined ("—").
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 rows = ((d && d.data) || [])
+ .filter((r) => r && r.name)
+ .map((r) => ({
+ name: r.name,
+ state: r.state || "",
+ auto_renew: !!r.auto_renew,
+ expires_on: r.expires_on || (r.expires_at ? String(r.expires_at).slice(0, 10) : ""),
+ // account id → the app can deep-link to the domain's page on dnsimple.com.
+ account_id: r.account_id || account,
+ }));
+ await mapLimit(rows, 8, async (row) => {
+ try {
+ const p = await api(token, "GET", `/v2/${account}/registrar/domains/${encodeURIComponent(row.name)}/prices`);
+ const price = p && p.data && (p.data.renewal_price ?? p.data.registration_price);
+ if (price != null) row.price = Number(price);
+ } catch {
+ /* pricing is best-effort — leave undefined */
+ }
+ });
+ return rows;
}
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/i;
diff --git a/agent/runtime.mjs b/agent/runtime.mjs
index 62d922a..dba4737 100644
--- a/agent/runtime.mjs
+++ b/agent/runtime.mjs
@@ -322,29 +322,36 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t
} catch {
return; // not sealed to us
}
+ // Reply ONLY with a non-rendering `connect.result` (matched by rid) — the
+ // DNSimple app renders the data itself. We never postReply here, so a
+ // connected-service op is NEVER visible as a bot message in the chat (the
+ // domain list belongs to the DNSimple app, not the bot's DM). Ops only ever
+ // come from the app, which always sets rid; a rid-less op gets no reply.
+ const reply = async (result) => {
+ if (!p.rid) return;
+ send({
+ type: "msg",
+ thread_id: threadId,
+ blob: await encrypt(entry.key, { type: "connect.result", service: "dnsimple", op: p.op, rid: p.rid, ...result, ts: Date.now() }),
+ });
+ };
if (!grant || (grant.exp && grant.exp < Date.now())) {
- await postReply(threadId, entry, "That authorization has expired — run the command again to re-issue it.");
+ await reply({ error: "That authorization expired — try again." });
return;
}
if (grant.service !== "dnsimple") {
- await postReply(threadId, entry, `I can't act on "${grant.service}" yet.`);
+ await reply({ error: `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.`);
+ await reply({ error: `Unknown action "${p.op}".` });
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.",
- );
+ await reply({ domains });
} catch (e) {
- await postReply(threadId, entry, `DNSimple error: ${e.message}`);
+ await reply({ error: e.message });
} finally {
grant.token = null; // drop the token
}
diff --git a/changelog/2026-07-05-dnsimple-app.md b/changelog/2026-07-05-dnsimple-app.md
new file mode 100644
index 0000000..884e9f6
--- /dev/null
+++ b/changelog/2026-07-05-dnsimple-app.md
@@ -0,0 +1,8 @@
+Once you connect DNSimple, it now installs as its own app — open it like any
+chat and you'll find your domains listed in its Interface view, with a Refresh to
+pull the latest. It's a companion app: a Chat view sits alongside the list, so
+what it fetches reads back as a conversation. Nothing new runs behind the scenes
+— the app asks the bot already in your chats to make the DNSimple call under a
+one-time sealed key, so the server still only ever handles scrambled data and
+your token never leaves your device except sealed for that single lookup. Being
+able to actually talk to it — "register this domain" — comes next, with AI.
diff --git a/docs/connected-services.md b/docs/connected-services.md
index e0d3ad2..9363921 100644
--- a/docs/connected-services.md
+++ b/docs/connected-services.md
@@ -68,24 +68,46 @@ origin and issues a token. No human in the loop, and safe: **you can only regist
an origin you control**, so no open-redirect and no code hijacking. (A self-hoster
running their *own* broker needs nothing from us.)
-### Next: connected services as companion apps (POC direction)
-
-Once connected, a service should *install as a companion app*: DNSimple appears in
-your app list, opens as its own thread with a **Chat** view (a bot-style
-conversation) and an **Interface** view (your domains), takes `/`-commands, and —
-with AI enabled — answers in natural language. It's a **client-side mini-app**, a
-"dynamic bot" — **no per-service sidecar**.
-
-The catch: DNSimple's API has no browser CORS (confirmed — same as the token
-endpoint), so the app can't call it directly. It **delegates each operation to the
-existing per-instance sidecar via a time-boxed grant** — exactly how `/list` works
-today; the sidecar makes the call and the result folds into the app's Interface +
-Chat. *Not* the broker: proxying per-call there would make the shared broker see
-every user's token on every call (breaking "shared infra leaks nothing"). On
-**native** there's no CORS wall, so the app could call the API directly.
+### Connected services as installed apps (prototyped)
+
+Once connected, a service *installs as its own app*. The DNSimple app
+(`web/static/apps/dnsimple.js`) is the working proof-of-concept: it appears in
+New chat **only after** you've linked DNSimple (a new `requiresService` gate on
+the app spec) and opens as its own thread showing a **table** of your domains
+(name → status, expiry, renewal price, auto-renew) — filterable, keyboard-navigable
+(↑/↓/Enter to open a domain on dnsimple.com), with a Refresh. It's a **takeover**
+app: the table *is* the thread, with no chat view, so a fetch never echoes the
+list into a chat bubble. (Conversational AI — "register this domain" — would
+reintroduce a chat view later, once there's a bot to actually converse with.)
+
+It's a **client-side mini-app**, a "dynamic bot" — **no per-service sidecar**.
+DNSimple's API has no browser CORS (confirmed — same as the token endpoint), so
+the app can't call it directly. It **delegates each operation to the existing
+per-instance sidecar via a time-boxed grant**: `ctx.host.connected.requestData`
+seals the token to the bot for one op, the sidecar makes the call and answers
+with a non-rendering `connect.result` (correlated by a request id), and the app
+folds the result into state (which syncs across your devices). The bot **never
+posts the result as a message** — it only returns the invisible `connect.result`,
+so connected-service data never surfaces in the bot's DM. *Not* the broker:
+proxying per-call there would make the shared broker see every user's token on
+every call (breaking "shared infra leaks nothing"). On **native** there's no CORS
+wall, so the app could later call the API directly.
+
+Known follow-ups from this POC:
+- **No slash command.** The Interface view *is* the domain list (auto-loaded, with
+ a filter + Refresh + per-domain links to dnsimple.com), so the app registers no
+ `/list` — a verb would be redundant, and routing it through the bot only risks
+ the bot speaking. If context-local commands are wanted later, the generic
+ resolver (every app claims a plain verb, resolved by the thread you're in) is the
+ follow-up.
+- **Per-bot "handles connect" capability flag** to replace `findBotThread`'s
+ name-ranking of Eleven/Claude.
+- **Sidebar gating is one-directional.** `requiresService` hides the app from
+ New chat until connected, but a thread already created stays in the sidebar if
+ you later disconnect (the app then shows a "not available" state).
Longer term these companion apps should be **installable and maintained outside
-this repo** rather than baked in; the in-repo `connect` app is the proof-of-concept.
+this repo** rather than baked in.
## The one split that unlocks it: storage vs. execution
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index 9ec7484..ab7a1db 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -13,6 +13,7 @@ import {
setScheduleTransport,
setEphemeralTransport,
setAppThreadResolver,
+ setHostBridge,
setCurrentUser,
setToast,
messageActionsFor,
@@ -40,6 +41,7 @@ import { app as pollApp, pollSummary } from "../web/static/apps/poll.js";
import { app as remindApp, parseReminder } from "../web/static/apps/remind.js";
import { app as bookmarksApp } from "../web/static/apps/bookmarks.js"; // registers "Save link"
import { app as boardApp, sanitizePoints } from "../web/static/apps/board.js";
+import { app as dnsimpleApp } from "../web/static/apps/dnsimple.js"; // registers the DNSimple companion app
import { app as chatApp, viewItems } from "../web/static/apps/chat.js"; // the chat app
// Each app's fold is synthesised from its `events` map by defineApp — the same
@@ -1050,5 +1052,50 @@ await test("Save link routes into the bookmarks app-thread with attribution", as
assert.equal(sent[0].event.from, "Bob", "records who posted the link");
});
+// ---- DNSimple companion app ---------------------------------------------
+
+const dnsimpleReduce = dnsimpleApp.reduce;
+
+await test("dnsimple: a fetched list replaces state; normalises + drops malformed rows", () => {
+ const s0 = dnsimpleApp.initialState();
+ assert.deepEqual(s0.domains, []);
+ // Structured rows (name + table fields) are stored as-is.
+ const rows = [
+ { name: "a.com", expires_on: "2027-01-01", auto_renew: true },
+ { name: "b.net", expires_on: "", auto_renew: false },
+ ];
+ const s1 = dnsimpleReduce(s0, { type: "dnsimple.domains", domains: rows, ts: 100 });
+ assert.deepEqual(s1.domains.map((d) => d.name), ["a.com", "b.net"]);
+ assert.equal(s1.domains[0].auto_renew, true, "table fields survive the fold");
+ assert.equal(s1.fetchedAt, 100);
+ // The latest refresh WINS (replace, not append) — dropped domains disappear.
+ const s2 = dnsimpleReduce(s1, { type: "dnsimple.domains", domains: [{ name: "a.com" }], ts: 200 });
+ assert.deepEqual(s2.domains.map((d) => d.name), ["a.com"]);
+ assert.equal(s2.fetchedAt, 200);
+ // Old string form is normalised to objects (back-compat with pre-table events).
+ const s3 = dnsimpleReduce(s2, { type: "dnsimple.domains", domains: ["x.com", "y.io"], ts: 300 });
+ assert.deepEqual(s3.domains.map((d) => d.name), ["x.com", "y.io"]);
+ // Hostile / malformed inputs fold to a no-op or drop bad entries (never crash).
+ assert.equal(dnsimpleReduce(s3, { type: "dnsimple.domains" }), s3, "missing domains → unchanged");
+ assert.equal(dnsimpleReduce(s3, { type: "dnsimple.domains", domains: "nope", ts: 400 }), s3, "non-array → unchanged");
+ const s4 = dnsimpleReduce(s3, { type: "dnsimple.domains", domains: [{ name: "ok.com" }, 42, null, {}], ts: 500 });
+ assert.deepEqual(s4.domains.map((d) => d.name), ["ok.com"], "entries without a string name are dropped");
+});
+
+await test("dnsimple: takeover app — the table is the whole thread, no chat echo", () => {
+ // No chatMessage → takeover (Interface only), so a fetched list never echoes as
+ // a chat bubble. The domain list lives in the table, nowhere else.
+ assert.equal(dnsimpleApp.chatMessage, undefined, "no chatMessage hook (takeover, not companion)");
+ assert.equal(appWantsChatView("dnsimple"), false, "no chatMessage → not a companion (takeover)");
+ assert.equal(dnsimpleApp.requiresService, "dnsimple", "gated on the connected service");
+});
+
+await test("dnsimple: registers no slash command (the Interface IS the list)", () => {
+ // No `/list` (or any) command — the domains live in the app's own view, so a
+ // verb would be redundant and only risks routing through (and waking) the bot.
+ assert.deepEqual(Object.keys(dnsimpleApp.commands || {}), []);
+ assert.equal(parseCommand("/list").app, null, "nothing owns /list");
+});
+
console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
diff --git a/test/connect.test.mjs b/test/connect.test.mjs
index a8dc6f2..6af26ab 100644
--- a/test/connect.test.mjs
+++ b/test/connect.test.mjs
@@ -67,12 +67,17 @@ function startMock() {
}
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" }] });
+ if (req.url.startsWith("/v2/4242/domains")) return send({ data: [{ name: "one.example", expires_on: "2027-01-01", auto_renew: true, state: "registered" }, { 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 } });
}
+ const pm = req.url.match(/^\/v2\/4242\/registrar\/domains\/([^/]+)\/prices$/);
+ if (pm) {
+ const d = decodeURIComponent(pm[1]);
+ return send({ data: { domain: d, premium: false, registration_price: 14.0, renewal_price: 14.0, transfer_price: 14.0 } });
+ }
send({ message: "not found" }, 404);
});
return new Promise((resolve) => server.listen(0, () => resolve(server)));
@@ -97,7 +102,11 @@ async function testClient() {
// /list — the wired, read-only command, using the exchanged token.
const domains = await listDomains(token);
- assert(domains.length === 2 && domains.includes("one.example"), "listDomains returns the account's domain names");
+ assert(domains.length === 2 && domains.some((d) => d.name === "one.example"), "listDomains returns structured domain rows");
+ const one = domains.find((d) => d.name === "one.example");
+ assert(one.expires_on === "2027-01-01" && one.auto_renew === true, "rows carry expiry + auto-renew for the table");
+ assert(one.account_id === 4242, "rows carry the account id so the app can deep-link to dnsimple.com");
+ assert(one.price === 14, "rows carry the renewal price (best-effort per-domain lookup) for the cost column");
const free = await checkAvailability("free.example", token);
assert(free.available === true, "an unregistered domain reports available");
diff --git a/web/static/apps.js b/web/static/apps.js
index 175c017..4b79fc9 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -1142,5 +1142,19 @@ export function renderApp(threadId, appName, el) {
if (!app || !app.render || !el) return;
runCleanups(threadId, app.name);
if (!app.incremental) el.textContent = "";
- app.render(appStateFor(threadId, app.name), el, makeCtx(threadId, app));
+ // A mini-app's render must NEVER take down the host. This surface can be the
+ // whole thread, and it's reached on boot (reopening your last thread) — an
+ // unguarded throw here white-screens the entire client ("Something went
+ // wrong"). Contain it: log, and show a localized, non-fatal message in the
+ // app's own pane so the rest of the app still loads.
+ try {
+ app.render(appStateFor(threadId, app.name), el, makeCtx(threadId, app));
+ } catch (e) {
+ console.error(`app "${app.name}" render failed:`, e);
+ el.textContent = "";
+ const p = document.createElement("p");
+ p.style.cssText = "padding:16px;color:var(--muted);font-size:13px;line-height:1.5";
+ p.textContent = `This view hit an error and couldn't load (${(e && e.message) || e}). Try reopening it.`;
+ el.appendChild(p);
+ }
}
diff --git a/web/static/apps/README.md b/web/static/apps/README.md
index 046025e..892e068 100644
--- a/web/static/apps/README.md
+++ b/web/static/apps/README.md
@@ -120,6 +120,7 @@ That's a whole app. Everything below is reference detail and the optional hooks.
| `title` | ✓ | Human name (sidebar, header). |
| `glyph` | ✓ | One emoji/character used as the app's avatar. |
| `scope` | ✓ | `"personal"` or `"shared"` — see below. |
+| `requiresService`| | A connected-service name (e.g. `"dnsimple"`). The app is hidden from New chat until that service is linked — how a service "installs" its companion app. See `docs/connected-services.md`. |
| `initialState` | ✓ | `() => state`. A **factory**, called to seed each thread's empty state. |
| `events` | ✓ | `{ "<type>": (state, event, meta) => state }`. The fold. |
| `commands` | | `{ "/cmd": (args, ctx) => void }`. Slash commands. |
diff --git a/web/static/apps/connect.js b/web/static/apps/connect.js
index 14c0f36..18ecd28 100644
--- a/web/static/apps/connect.js
+++ b/web/static/apps/connect.js
@@ -6,8 +6,9 @@
// encrypted blob. The privileged crypto lives in shell.js and is reached via
// ctx.host.connected (an app can't touch the session keypair, roster, or fetch).
//
-// Commands: `/connect` opens this surface; `/list` asks the chat's bot to list
-// your domains (answered in place; the bot replies in your chat with it).
+// Command: `/connect` opens this surface. (Listing domains is not a generic
+// verb owned here — it belongs to the service you connected: the DNSimple app
+// owns a context-local `/list` that only works inside its own thread.)
import { defineApp } from "../apps.js";
const unavailable = (ctx) => ctx.reply({ text: "Connecting services isn't available here yet." });
@@ -28,23 +29,6 @@ export const app = defineApp({
ctx.reply({ text: "Open Connected services to link an account — you'll authorize on the provider's site, no key to paste.", actions: [{ label: "Open Connected services", run: ctx.open }] });
},
},
- // 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}`,
- });
- },
- },
},
render(_state, el, ctx) {
@@ -107,7 +91,25 @@ export const app = defineApp({
btn.onclick = async () => {
if (busy) return;
if (on) {
- await host.disconnect("dnsimple");
+ // Confirm first — disconnecting removes the stored token. Inline (no
+ // floating dialog), per the house style: the prompt occupies layout.
+ note.textContent = "";
+ const confirm = document.createElement("div");
+ confirm.className = "connect-confirm";
+ const q = document.createElement("span");
+ q.textContent = "Disconnect DNSimple? Your saved token will be removed from this server.";
+ const yes = document.createElement("button");
+ yes.type = "button";
+ yes.className = "connect-btn connect-danger";
+ yes.textContent = "Disconnect";
+ yes.onclick = () => host.disconnect("dnsimple"); // onChange repaints + notes on completion
+ const cancel = document.createElement("button");
+ cancel.type = "button";
+ cancel.className = "connect-btn connect-cancel";
+ cancel.textContent = "Cancel";
+ cancel.onclick = () => (note.textContent = "");
+ confirm.append(q, yes, cancel);
+ note.appendChild(confirm);
return;
}
note.textContent = "";
@@ -143,5 +145,9 @@ export const app = defineApp({
.connect-btn { padding: 8px 14px; border-radius: 9px; border: 0; background: var(--accent, #2f6fed); color: #fff; font: inherit; font-weight: 600; cursor: pointer; }
.connect-btn:disabled { opacity: .6; cursor: default; }
.connect-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted, #667); }
+ .connect-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+ .connect-confirm span { flex: 1 1 200px; color: var(--ink, #222); }
+ .connect-danger { background: #d64545; }
+ .connect-cancel { background: transparent; color: var(--muted, #667); border: 1px solid var(--line, #e3e3e8); }
`,
});
diff --git a/web/static/apps/dnsimple.js b/web/static/apps/dnsimple.js
new file mode 100644
index 0000000..73fcbd6
--- /dev/null
+++ b/web/static/apps/dnsimple.js
@@ -0,0 +1,325 @@
+// The DNSimple companion app (docs/connected-services.md § companion apps). Once
+// you've linked DNSimple (the "Connected services" surface), this app "installs":
+// it appears in New chat and opens as its own thread, where the Interface view
+// lists your domains and the Chat view reads back as a conversation with it.
+//
+// There is NO extra sidecar and no new bot: the app fetches your domains through
+// the host bridge (ctx.host.connected.requestData), which asks the instance's
+// EXISTING bot to run the DNSimple call under a one-op, sealed grant and hand the
+// result straight back. The browser can't call DNSimple itself (no CORS), and the
+// server only ever relays ciphertext — the token never leaves your trust domain
+// except sealed to the bot for that single call.
+//
+// It's a COMPANION app: implementing chatMessage(e) opts into the Chat | Interface
+// split (see apps/README.md). Your domain list is folded state, so it persists
+// across devices and survives reopen via the snapshot, just like any other app.
+import { defineApp } from "../apps.js";
+
+// A tiny helper so both the Chat projection and the reducer agree on wording.
+const summary = (domains) =>
+ domains.length ? `${domains.length} domain${domains.length === 1 ? "" : "s"}` : "no domains yet";
+
+// Domains arrive as { name, expires_on, auto_renew, state } objects; tolerate the
+// older string form (and anything malformed) by normalising to an object with a
+// name, dropping the rest.
+const normalizeDomains = (list) =>
+ (Array.isArray(list) ? list : [])
+ .map((d) => (typeof d === "string" ? { name: d } : d))
+ .filter((d) => d && typeof d.name === "string");
+
+// The domain's page on dnsimple.com, or "" if we don't have the account id (an
+// older event before we captured it). encodeURIComponent the name for safety.
+const domainUrl = (d) =>
+ d && d.account_id && d.name ? `https://dnsimple.com/a/${encodeURIComponent(d.account_id)}/domains/${encodeURIComponent(d.name)}` : "";
+
+// Module-level UI state (not folded — transient to the surface): `busy` drives the
+// "Refreshing…" affordance, `autoLoading` guards the one-shot open fetch, and
+// `filterText` is the live filter box value (kept across re-renders).
+let busy = false;
+let autoLoading = false;
+let filterText = "";
+
+export const app = defineApp({
+ name: "dnsimple",
+ title: "DNSimple",
+ glyph: "🌐",
+ scope: "personal",
+ // Only listed in New chat once DNSimple is linked (shell.js gates on this).
+ requiresService: "dnsimple",
+ initialState: () => ({ domains: [], fetchedAt: 0 }),
+
+ // One event: a fetched domain list. The latest refresh wins (replace, not
+ // append). Validate defensively — a malformed event folds to a no-op.
+ events: {
+ "dnsimple.domains": (s, e) =>
+ Array.isArray(e.domains) ? { domains: normalizeDomains(e.domains), fetchedAt: e.ts || s.fetchedAt } : s,
+ },
+
+ // Takeover app (NO chatMessage): the domain table IS the whole thread — no Chat
+ // view, so a fetch never echoes the list into a chat bubble. (Conversational AI
+ // would re-introduce a Chat view later, once there's a bot to actually talk to.)
+ // No commands either: the Interface auto-loads + Refreshes; a /list verb would be
+ // redundant, and routing it through the bot only risks the bot speaking.
+
+ styles: `
+.dns-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; }
+.dns-title { margin: 0; font-size: 18px; }
+.dns-sub { margin-left: auto; font-size: 12px; color: var(--muted); }
+.dns-refresh { flex: 0 0 auto; padding: 6px 12px; border-radius: 9px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); font: inherit; cursor: var(--ui-cursor, pointer); }
+.dns-refresh:disabled { opacity: .6; cursor: default; }
+.dns-filter { width: 100%; box-sizing: border-box; padding: 8px 12px; margin: 0 0 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); font: inherit; }
+.dns-filter:focus { outline: none; border-color: var(--accent); }
+.dns-scroll { overflow-x: auto; margin: 0 -4px; }
+.dns-table { width: 100%; border-collapse: collapse; font-size: 14px; }
+.dns-table th { text-align: left; font-weight: 600; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding: 6px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
+.dns-table td { padding: 9px 10px; border-bottom: 1px solid var(--border); }
+.dns-table tbody tr { cursor: default; }
+.dns-table tbody tr:hover { background: var(--bubble-them); }
+.dns-table tbody tr.dns-active { background: var(--bubble-them); box-shadow: inset 2px 0 0 var(--accent); }
+.dns-name { font-weight: 500; overflow-wrap: anywhere; }
+.dns-name a { color: var(--accent); text-decoration: none; }
+.dns-name a:hover { text-decoration: underline; }
+.dns-status { color: var(--muted); text-transform: capitalize; white-space: nowrap; }
+.dns-exp { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
+.dns-price { font-variant-numeric: tabular-nums; }
+.dns-c { text-align: right; white-space: nowrap; }
+.dns-on { color: #1a8f4c; }
+.dns-empty { color: var(--muted); padding: 8px 0; }
+.dns-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted); }
+`,
+
+ render(state, el, ctx) {
+ const host = ctx.host && ctx.host.connected;
+ // appStateFor returns undefined for a thread with no folded state yet (a
+ // freshly-opened app-thread), so normalise before touching any field — an
+ // unguarded state.fetchedAt here is exactly what crashed the pane.
+ state = state || { domains: [], fetchedAt: 0 };
+ // Normalise here too, not just in the reducer: a snapshot folded by an older
+ // version may hold the string form, which is seeded straight in (bypassing the
+ // reducer) — the table render needs objects with a .name.
+ const domains = normalizeDomains(state.domains);
+
+ // Keyboard-nav state (typeahead style): the active row index, plus the rows
+ // currently shown and their <tr> nodes so ↑/↓/Enter can drive them.
+ let activeIndex = 0;
+ let currentRows = [];
+ let rowEls = [];
+
+ const head = document.createElement("div");
+ head.className = "dns-head";
+ const title = document.createElement("h2");
+ title.className = "dns-title";
+ title.textContent = "Your domains";
+ const sub = document.createElement("span");
+ sub.className = "dns-sub";
+ const refresh = document.createElement("button");
+ refresh.type = "button";
+ refresh.className = "dns-refresh";
+ head.append(title, sub, refresh);
+ el.appendChild(head);
+
+ // Filter box — value kept in module state so it survives re-renders. Hidden
+ // until there are domains to filter (drawList decides).
+ const filter = document.createElement("input");
+ filter.className = "dns-filter";
+ filter.type = "search";
+ filter.placeholder = "Filter domains… (↑ ↓ to move, Enter to open)";
+ filter.value = filterText;
+ filter.hidden = true;
+ filter.addEventListener("input", () => {
+ filterText = filter.value;
+ activeIndex = 0; // a new filter re-anchors selection to the top match
+ drawList();
+ });
+ // Typeahead keyboard nav: move the active row and open it, right from the box.
+ filter.addEventListener("keydown", (e) => {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setActive(activeIndex + 1);
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setActive(activeIndex - 1);
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ openActive();
+ } else if (e.key === "Escape" && filterText) {
+ e.preventDefault();
+ filter.value = "";
+ filterText = "";
+ activeIndex = 0;
+ drawList();
+ }
+ });
+ el.appendChild(filter);
+
+ const listOrEmpty = document.createElement("div");
+ el.appendChild(listOrEmpty);
+
+ const note = document.createElement("div");
+ note.className = "dns-note";
+ el.appendChild(note);
+
+ // No privileged bridge means we're in the native app (no session key to seal
+ // a grant with) or on a stale page that predates requestData — connected
+ // services are web-only for now. Say so loudly, not with a muted note.
+ if (!host || !host.requestData) {
+ sub.textContent = "";
+ refresh.hidden = true;
+ listOrEmpty.className = "dns-empty";
+ listOrEmpty.textContent = "DNSimple needs the web app for now — open this chat in a web browser (connecting isn't supported in the app yet).";
+ return;
+ }
+
+ const drawList = () => {
+ filter.hidden = !domains.length; // only offer the filter once there's something to filter
+ listOrEmpty.textContent = "";
+ if (!domains.length) {
+ currentRows = [];
+ rowEls = [];
+ listOrEmpty.className = "dns-empty";
+ listOrEmpty.textContent = state.fetchedAt ? "No domains in this account yet." : "No domains loaded yet — hit Refresh.";
+ return;
+ }
+ const q = filterText.trim().toLowerCase();
+ const rows = domains
+ .filter((d) => !q || d.name.toLowerCase().includes(q))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ currentRows = rows;
+ rowEls = [];
+ if (!rows.length) {
+ listOrEmpty.className = "dns-empty";
+ listOrEmpty.textContent = `No domains match “${filterText.trim()}”.`;
+ return;
+ }
+ activeIndex = Math.max(0, Math.min(activeIndex, rows.length - 1));
+ listOrEmpty.className = "";
+ const scroll = document.createElement("div");
+ scroll.className = "dns-scroll";
+ const table = document.createElement("table");
+ table.className = "dns-table";
+
+ const thead = document.createElement("thead");
+ const hr = document.createElement("tr");
+ for (const [label, cls] of [["Domain", ""], ["Status", ""], ["Expires", "dns-exp"], ["Renewal", "dns-c"], ["Auto-renew", "dns-c"]]) {
+ const th = document.createElement("th");
+ th.textContent = label;
+ if (cls === "dns-c") th.className = "dns-c";
+ hr.appendChild(th);
+ }
+ thead.appendChild(hr);
+ table.appendChild(thead);
+
+ const tbody = document.createElement("tbody");
+ rows.forEach((d, i) => {
+ const tr = document.createElement("tr");
+ tr.onclick = () => setActive(i); // clicking a row makes it the keyboard-active one
+
+ const nameCell = document.createElement("td");
+ nameCell.className = "dns-name";
+ // Link straight to the domain's page on dnsimple.com when we have the
+ // account id; otherwise plain text. Never innerHTML — build the node.
+ const url = domainUrl(d);
+ if (url) {
+ const a = document.createElement("a");
+ a.href = url;
+ a.target = "_blank";
+ a.rel = "noopener noreferrer";
+ a.textContent = d.name;
+ nameCell.appendChild(a);
+ } else {
+ nameCell.textContent = d.name;
+ }
+
+ const statusCell = document.createElement("td");
+ statusCell.className = "dns-status";
+ statusCell.textContent = d.state || "—"; // e.g. "registered" / "hosted"
+
+ const expCell = document.createElement("td");
+ expCell.className = "dns-exp";
+ expCell.textContent = d.expires_on || "—";
+
+ const priceCell = document.createElement("td");
+ priceCell.className = "dns-c dns-price";
+ priceCell.textContent = typeof d.price === "number" ? `$${d.price.toFixed(2)}` : "—";
+
+ const renewCell = document.createElement("td");
+ renewCell.className = "dns-c" + (d.auto_renew ? " dns-on" : "");
+ renewCell.textContent = d.auto_renew ? "On" : "Off";
+
+ tr.append(nameCell, statusCell, expCell, priceCell, renewCell);
+ rowEls.push(tr);
+ tbody.appendChild(tr);
+ });
+ table.appendChild(tbody);
+ scroll.appendChild(table);
+ listOrEmpty.appendChild(scroll);
+ highlight();
+ // Focus the filter so ↑/↓/Enter work immediately — but only when focus is
+ // free (never steal it from the chat composer). On a re-render the old,
+ // focused filter was removed so activeElement falls back to <body>, which
+ // lets us restore focus to the fresh one.
+ if (!document.activeElement || document.activeElement === document.body) filter.focus({ preventScroll: true });
+ };
+
+ // Keyboard-nav helpers (used by the filter's keydown + row clicks).
+ const highlight = () => {
+ rowEls.forEach((tr, i) => tr.classList.toggle("dns-active", i === activeIndex));
+ const active = rowEls[activeIndex];
+ if (active && active.scrollIntoView) active.scrollIntoView({ block: "nearest" });
+ };
+ const setActive = (i) => {
+ if (!currentRows.length) return;
+ activeIndex = Math.max(0, Math.min(i, currentRows.length - 1));
+ highlight();
+ };
+ const openActive = () => {
+ const url = domainUrl(currentRows[activeIndex]);
+ if (url) window.open(url, "_blank", "noopener");
+ };
+
+ // Transient bits (busy label, count line) live outside folded state, so paint
+ // them directly rather than waiting for a re-render.
+ const paint = () => {
+ refresh.textContent = busy ? "Refreshing…" : "↻ Refresh";
+ refresh.disabled = busy;
+ sub.textContent = busy ? "loading…" : domains.length ? summary(domains) : "";
+ };
+
+ const refreshNow = async () => {
+ if (busy) return;
+ busy = true;
+ note.textContent = "";
+ paint();
+ let res;
+ try {
+ res = await host.requestData("dnsimple", "list");
+ } finally {
+ busy = false;
+ autoLoading = false;
+ }
+ if (res && res.ok) {
+ // Emit the list as an event: it folds into state (so the Interface and
+ // Chat both update and it syncs across your devices) and triggers a
+ // re-render from the framework with the fresh domains.
+ ctx.emit({ type: "dnsimple.domains", domains: res.domains || [] });
+ } else {
+ // Loud, in the main area — a failed load must never look like "no domains".
+ const msg = (res && res.error) || "Couldn't reach DNSimple just now.";
+ listOrEmpty.className = "dns-empty";
+ listOrEmpty.textContent = domains.length ? "" : `Couldn't load your domains: ${msg}`;
+ note.textContent = domains.length ? `Couldn't refresh: ${msg}` : "";
+ paint();
+ }
+ };
+ refresh.onclick = refreshNow;
+
+ drawList();
+ paint();
+
+ // Opened with nothing loaded yet → pull the list once, automatically.
+ if (!state.fetchedAt && !busy && !autoLoading) {
+ autoLoading = true;
+ refreshNow();
+ }
+ },
+});
diff --git a/web/static/shell.js b/web/static/shell.js
index 50b8d7f..b8c1a25 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -84,6 +84,7 @@ import "/static/apps/bookmarks.js";
import "/static/apps/poll.js";
import "/static/apps/board.js";
import "/static/apps/connect.js";
+import "/static/apps/dnsimple.js";
import { startTour } from "/static/apps/tutorial.js"; // also registers the /tutorial floor command
import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice } from "/static/apps/chat.js";
import { parseInviteFragment, buildInviteFragment } from "/static/invite.js";
@@ -1858,6 +1859,24 @@ async function awaitConnectToken(threadId, key, service, tries = 24) {
return null;
}
+// Poll a thread for the bot's connect.result reply to one op, matched by the
+// request id we minted (rid) so a stale/older result for the same service can't
+// be mistaken for this one. Returns the whole payload ({ domains } or { error }).
+async function awaitConnectResult(threadId, key, rid, tries = 24) {
+ for (let i = 0; i < tries; i++) {
+ await new Promise((r) => setTimeout(r, 500));
+ const r = await fetch(`/api/messages?thread=${encodeURIComponent(threadId)}&token=${encodeURIComponent(session.token)}&limit=20`);
+ const { messages = [] } = r.ok ? await r.json() : {};
+ for (const m of messages) {
+ try {
+ const p = await decrypt(key, m.blob);
+ if (p.type === "connect.result" && p.rid === rid) return p;
+ } catch {}
+ }
+ }
+ return null;
+}
+
let oauthPending = null; // { service, state }
const oauthCfgCache = {}; // service -> { client_id, authorize_url, redirect_uri }
let oauthCompleting = false; // true while a returned code is being exchanged (drives the "Connecting…" state)
@@ -2027,6 +2046,38 @@ setHostBridge({
return { ok: true, bot: target.bot.label || "the bot" };
})();
},
+ // Like request(), but a request/response round-trip: the bot answers with a
+ // structured, non-rendering `connect.result` (matched by rid) that we return
+ // to the caller — so an app (e.g. the DNSimple companion) can render the data
+ // itself instead of it just landing as a chat message. Same grant model: the
+ // token is sealed to the bot for one op, opened only in its trust domain.
+ requestData(service, op, arg, ttlMs = 120000) {
+ return (async () => {
+ if (!session.userPriv) return { ok: false, error: "connecting services isn't supported in the native app yet" };
+ const target = findBotThread();
+ if (!target) return { ok: false, error: "start a chat with a bot first — it does the work" };
+ const key = threadKeys[target.thread.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` };
+ let opened;
+ try {
+ opened = await openSealed(got.blob, session.userPriv);
+ } catch {
+ return { ok: false, error: "couldn't reopen your stored credential" };
+ }
+ const rid = bytesToB64url(crypto.getRandomValues(new Uint8Array(12)));
+ const grant = await sealForPub(target.bot.public_key, { service, token: opened.secret, exp: Date.now() + ttlMs });
+ const payload = { type: "connect.op", service, op, domain: arg, grant, rid, name: session.name, ts: Date.now() };
+ if (myUserId) payload.uid = myUserId;
+ ws.send(JSON.stringify({ type: "msg", thread_id: target.thread.id, blob: await encrypt(key, payload) }));
+ const res = await awaitConnectResult(target.thread.id, key, rid);
+ if (!res) return { ok: false, error: "the bot didn't respond — is it running?" };
+ if (res.error) return { ok: false, error: res.error };
+ return { ok: true, ...res };
+ })();
+ },
},
});
@@ -4354,8 +4405,25 @@ function showNewChat() {
// though: tapping one opens (or reopens) its personal app-thread via
// resolveAppThread, never createThread. matchingApps filters the registry's
// scope:"personal" set by the same query as the people search.
+ //
+ // An app may gate on a connected service (`requiresService`): DNSimple's app
+ // only "installs" once you've linked DNSimple. We learn the linked set from
+ // /api/connected; until it arrives (or if the fetch fails) gated apps stay
+ // hidden — no flash of a not-yet-usable app — then we re-render.
+ let connectedServices = null; // null = unknown yet; a Set once loaded
+ fetch(`/api/connected?token=${encodeURIComponent(session.token)}`)
+ .then((r) => (r.ok ? r.json() : { services: [] }))
+ .then((j) => {
+ connectedServices = new Set(j.services || []);
+ render(currentMode);
+ })
+ .catch(() => (connectedServices = new Set()));
const matchingApps = (q) =>
- personalApps().filter((a) => (a.title || a.name).toLowerCase().includes(q));
+ personalApps().filter(
+ (a) =>
+ (a.title || a.name).toLowerCase().includes(q) &&
+ (!a.requiresService || (connectedServices && connectedServices.has(a.requiresService))),
+ );
const addAppRow = (listEl, app) => {
const b = document.createElement("button");
b.className = "list-row";
@@ -4374,7 +4442,9 @@ function showNewChat() {
};
// "people": tap a name → DM. "group": multi-select + name → group.
+ let currentMode = "people"; // tracked so the async connected-fetch can re-render in place
const render = (mode) => {
+ currentMode = mode;
card.innerHTML = "";
const head = document.createElement("div");
head.className = "sheet-head";