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>
11 files changed,
+599
−54
agent/dnsimple.mjs+39 −3agent/runtime.mjs+18 −11changelog/2026-07-05-dnsimple-app.md+8 −0docs/connected-services.md+38 −16test/apps.test.mjs+47 −0test/connect.test.mjs+11 −2web/static/apps.js+15 −1web/static/apps/README.md+1 −0web/static/apps/connect.js+26 −20web/static/apps/dnsimple.js+325 −0web/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"; |