// Integrations — the `connect` app (prototype) — docs/connected-services.md. // // A registry-driven catalog for linking third-party accounts so a bot can act // for you. Each service comes from the integrations registry (one reviewed // manifest per service, served at /api/integrations) and offers two ways in: // authorize on the provider's own site (OAuth PKCE — no key pasted, no client // secret exists), or paste a scoped token. Either way the credential is sealed // in your browser and the server only ever holds an opaque, client-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). // // 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, namespace, appByName, openAppThread } from "../apps.js"; import { LOGO_PLUG } from "../logos.js"; // One declaration of the app's prefix, reused everywhere: N.cls("card") → the // "connect-card" CSS class. Change "connect" here and every string below follows. const N = namespace("connect"); const unavailable = (ctx) => ctx.reply({ text: "Connecting services isn't available here yet." }); // When the registry can't be fetched, render what the surface always offered — // the DNSimple row — so nothing regresses on an old server or a flaky load. const FALLBACK_CATALOG = [ { id: "dnsimple", name: "DNSimple", category: "domains", auth: { kind: "both", pat_help: "https://support.dnsimple.com/articles/api-access-token/ — create a token scoped to the domains you want managed", }, ops: [], }, ]; // The first http(s) URL inside a manifest's pat_help (the field is prose that // leads with the link). Only http(s) ever becomes an href. function patHelpURL(svc) { const m = /https?:\/\/[^\s]+/.exec((svc.auth && svc.auth.pat_help) || ""); return m ? m[0] : ""; } // One muted line of what connecting allows, straight from the manifest's closed // op list: "Reads: List domains, Check availability · Writes (always // confirmed): Register a domain". function opsSummary(svc) { const label = (o) => o.label || o.id; const reads = (svc.ops || []).filter((o) => o.access === "read").map(label); const writes = (svc.ops || []).filter((o) => o.access === "write").map(label); const parts = []; if (reads.length) parts.push(`Reads: ${reads.join(", ")}`); if (writes.length) parts.push(`Writes (always confirmed): ${writes.join(", ")}`); return parts.join(" · "); } export const app = defineApp({ name: "connect", title: "Integrations", // A one-line blurb shown as the sidebar subtitle before the thread has any // activity (mirrored natively in ThreadStore.appDescriptions — keep in sync). blurb: "Connect third-party services", glyph: "🔌", mark: "plug", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md) logo: LOGO_PLUG, places: ["you"], placeNote: "Connections use your own account credentials — each person links their own.", initialState: () => ({}), events: {}, commands: { "/connect": { desc: "Connect a third-party service", args: "", run: (_args, ctx) => { if (!ctx.host || !ctx.host.connected) return unavailable(ctx); ctx.reply({ text: "Open Integrations to link an account — you'll authorize on the provider's site, no key to paste.", actions: [{ label: "Open Integrations", run: ctx.open }] }); }, }, }, render(_state, el, ctx) { el.textContent = ""; const wrap = document.createElement("div"); wrap.className = N.cls("app"); const h = document.createElement("h2"); h.className = N.cls("h"); h.textContent = "Integrations"; wrap.appendChild(h); const sub = document.createElement("p"); sub.className = N.cls("sub"); sub.textContent = "Link an account so a bot can act for you — authorize on the provider's own site, or paste a scoped token. Either way the key is sealed in your browser; the server never sees it."; wrap.appendChild(sub); const list = document.createElement("div"); list.className = N.cls("list"); wrap.appendChild(list); const note = document.createElement("div"); note.className = N.cls("note"); wrap.appendChild(note); el.appendChild(wrap); const host = ctx.host && ctx.host.connected; let busy = false; // an OAuth start in flight let catalog; // undefined until fetched; null/[] = fetch failed → fallback // Whether a service can actually start an OAuth flow here (manifest says // oauth-capable AND the broker has an app registered). null = unknown yet; // the OAuth button renders only once this confirms, so a service whose app // isn't registered offers paste-a-token alone instead of a dead button. const oauthOK = {}; const oauthCapable = (svc) => { const kind = (svc.auth && svc.auth.kind) || ""; return kind === "oauth" || kind === "both"; }; // The access cap: what this connection is ALLOWED to do, chosen by the // member. capChoice is the pre-connect pick (default read-only — least // privilege unless you say otherwise); capNow is the cap read back off a // connected credential (undefined = a legacy connection from before caps, // which stays unrestricted until the member sets one). const capChoice = {}; const capNow = {}; const capLabel = (cap) => (cap === "read" ? "read only" : "read & write"); // The pre-connect access picker: two mutually-exclusive options rendered // as a small segmented row. Applies to BOTH connect paths (OAuth and // paste-a-token) — the choice is about the connection, not the mechanism. function buildCapPicker(svc) { const wrap = document.createElement("div"); wrap.className = "connect-cap"; wrap.setAttribute("role", "radiogroup"); wrap.setAttribute("aria-label", `${svc.name || svc.id} access`); const lab = document.createElement("span"); lab.className = "connect-cap-label"; lab.textContent = "Allow:"; wrap.appendChild(lab); const current = () => capChoice[svc.id] || "read"; for (const [cap, text] of [["read", "Read only"], ["write", "Read & write"]]) { const b = document.createElement("button"); b.type = "button"; b.className = "connect-cap-btn" + (current() === cap ? " on" : ""); b.setAttribute("role", "radio"); b.setAttribute("aria-checked", String(current() === cap)); b.textContent = text; b.onclick = () => { capChoice[svc.id] = cap; for (const el of wrap.querySelectorAll(".connect-cap-btn")) { const isOn = el === b; el.classList.toggle("on", isOn); el.setAttribute("aria-checked", String(isOn)); } }; wrap.appendChild(b); } return wrap; } // One catalog row: name · category · status, then the action buttons and // the (collapsed) paste-a-token detail. All text via textContent. function buildRow(svc, on, pending) { const item = document.createElement("div"); item.className = "connect-item"; const row = document.createElement("div"); row.className = "connect-row"; const name = document.createElement("span"); name.className = "connect-name"; name.textContent = svc.name || svc.id; const cat = document.createElement("span"); cat.className = "connect-cat"; cat.textContent = svc.category || ""; const status = document.createElement("span"); status.className = "connect-status"; const capText = on && capNow[svc.id] ? ` · ${capLabel(capNow[svc.id])}` : ""; status.textContent = pending ? "Connecting…" : on ? `Connected ✓${capText}` : "Not connected"; status.classList.toggle("on", on && !pending); row.append(name, cat, status); item.appendChild(row); // Choose the connection's access BEFORE connecting — the cap seals into // the stored credential and grants can never exceed it. if (!on && !pending) item.appendChild(buildCapPicker(svc)); const actions = document.createElement("div"); actions.className = "connect-actions"; item.appendChild(actions); const detail = document.createElement("div"); detail.className = "connect-detail"; detail.hidden = true; item.appendChild(detail); const rowNote = document.createElement("div"); rowNote.className = "connect-rownote"; item.appendChild(rowNote); // The golden path: authorize on the provider's own site whenever the // broker has an OAuth app for this service (oauthOK, confirmed against // the broker's config) — pasting a token is the fallback below. if (!on && oauthOK[svc.id]) { const oauthBtn = document.createElement("button"); oauthBtn.type = "button"; oauthBtn.className = "connect-btn"; oauthBtn.textContent = busy || pending ? "Connecting…" : `Connect with ${svc.name || svc.id}`; oauthBtn.disabled = busy || pending; oauthBtn.onclick = async () => { if (busy || pending) return; note.textContent = ""; busy = true; paint(); const res = await host.startOAuth(svc.id, capChoice[svc.id] || "read"); busy = false; paint(); if (!res.ok) note.textContent = res.error || "Couldn't start connecting."; }; actions.appendChild(oauthBtn); } // Connected, and the service has a companion app (DNSimple's domains, // GitHub's repos): the primary action becomes going there — the whole // point of connecting. Created on first use, like any personal app. if (on && appByName(svc.id)) { const openBtn = document.createElement("button"); openBtn.type = "button"; openBtn.className = "connect-btn"; openBtn.textContent = `Open ${svc.name || svc.id}`; openBtn.onclick = () => openAppThread(svc.id); actions.appendChild(openBtn); } // Every service takes a pasted token — sealed to your own key by the // host bridge before it leaves this page (the same storage path the // OAuth completion uses). const pasteBtn = document.createElement("button"); pasteBtn.type = "button"; pasteBtn.className = "connect-btn connect-secondary"; pasteBtn.textContent = on ? "Replace token" : "Paste a token"; pasteBtn.onclick = () => { detail.hidden = !detail.hidden; if (!detail.hidden) input.focus(); }; actions.appendChild(pasteBtn); const tokenRow = document.createElement("div"); tokenRow.className = "connect-tokenrow"; const input = document.createElement("input"); input.type = "password"; input.className = "connect-input"; input.placeholder = `Paste your ${svc.name || svc.id} token`; input.autocomplete = "off"; const save = document.createElement("button"); save.type = "button"; save.className = "connect-btn"; save.textContent = "Save"; save.onclick = async () => { const v = input.value.trim(); if (!v) { rowNote.textContent = "Paste a token first."; return; } rowNote.textContent = ""; save.disabled = true; save.textContent = "Saving…"; const res = await host.store(svc.id, v, capChoice[svc.id] || "read"); save.disabled = false; save.textContent = "Save"; if (!res.ok) { rowNote.textContent = res.error || "Couldn't save the token."; return; } input.value = ""; note.textContent = `${svc.name || svc.id} connected — your token was sealed in your browser; the server can't read it.`; paint(); // Best-effort verification: when the manifest declares a "viewer" read // op (whoami-style), run it once through the bot so a typo'd token // surfaces NOW, not on first real use. The token stays saved either // way (they can Replace it); no bot online just means "couldn't check". const viewer = (svc.ops || []).find((o) => o.id === "viewer" && o.access === "read"); if (viewer && host.requestOp) { const check = await host.requestOp(svc.id, "viewer", {}); let who = ""; if (check.ok && typeof check.data === "string") { try { const d = JSON.parse(check.data); who = d && (d.login || d.name) ? ` as ${d.login || d.name}` : ""; } catch {} } note.textContent = check.ok ? `${svc.name || svc.id} connected${who} ✓ — your token was sealed in your browser; the server can't read it.` : `${svc.name || svc.id} token saved, but it didn't verify: ${check.error || "no response"}. Double-check the token (Replace token to swap it).`; } }; tokenRow.append(input, save); detail.appendChild(tokenRow); const helpURL = patHelpURL(svc); if (helpURL) { const help = document.createElement("a"); help.className = "connect-help"; help.href = helpURL; help.target = "_blank"; help.rel = "noopener noreferrer"; help.textContent = "Create a scoped token"; detail.appendChild(help); } const sum = opsSummary(svc); if (sum) { const ops = document.createElement("div"); ops.className = "connect-ops"; ops.textContent = sum; detail.appendChild(ops); } if (on && host.setCap) { // Flip the connection's cap in place (re-seals the credential — needs // no re-paste and no provider round-trip). Loosening to read & write // is an explicit tap; note that an OAuth token minted under read-only // provider scopes stays read-limited at the provider regardless. const flip = capNow[svc.id] === "read" ? "write" : "read"; const capBtn = document.createElement("button"); capBtn.type = "button"; capBtn.className = "connect-btn connect-secondary"; capBtn.textContent = flip === "write" ? "Allow writes" : "Make read-only"; capBtn.onclick = async () => { capBtn.disabled = true; const res = await host.setCap(svc.id, flip); capBtn.disabled = false; if (!res.ok) { rowNote.textContent = res.error || "Couldn't change access."; return; } capNow[svc.id] = flip; note.textContent = `${svc.name || svc.id} is now ${capLabel(flip)}.`; paint(); }; actions.appendChild(capBtn); } if (on) { const disc = document.createElement("button"); disc.type = "button"; disc.className = "connect-btn connect-cancel"; disc.textContent = "Disconnect"; disc.onclick = () => { // Confirm first — disconnecting removes the stored token. Inline (no // floating dialog), per the house style: the prompt occupies layout. rowNote.textContent = ""; const confirm = document.createElement("div"); confirm.className = N.cls("confirm"); const q = document.createElement("span"); q.textContent = `Disconnect ${svc.name || svc.id}? Your saved token will be removed from this server.`; const yes = document.createElement("button"); yes.type = "button"; yes.className = `${N.cls("btn")} ${N.cls("danger")}`; yes.textContent = "Disconnect"; yes.onclick = () => host.disconnect(svc.id); // onChange repaints + notes on completion const cancel = document.createElement("button"); cancel.type = "button"; cancel.className = `${N.cls("btn")} ${N.cls("cancel")}`; cancel.textContent = "Cancel"; cancel.onclick = () => (rowNote.textContent = ""); confirm.append(q, yes, cancel); rowNote.appendChild(confirm); }; actions.appendChild(disc); } return item; } // Repaints rebuild the list; a sequence counter keeps an older in-flight // paint from appending duplicate rows over a newer one. let paintSeq = 0; async function paint() { const seq = ++paintSeq; if (!host) { list.textContent = ""; const p = document.createElement("div"); p.className = "connect-ops"; p.textContent = "Connecting services isn't available here yet."; list.appendChild(p); return; } if (catalog === undefined) catalog = host.catalog ? await host.catalog() : null; const connected = await host.list(); const services = catalog && catalog.length ? catalog : FALLBACK_CATALOG; // Resolve which oauth-capable services the broker actually offers (each // answer is cached by the bridge, so repaints are cheap) — and warm the // config so a Connect click can open the consent page directly. Connected // services also read back their access cap for the status line. await Promise.all([ ...services.filter(oauthCapable).map(async (svc) => { if (oauthOK[svc.id] === undefined && host.oauthReady) oauthOK[svc.id] = await host.oauthReady(svc.id); }), ...services.map(async (svc) => { if (!connected.includes(svc.id) || !host.status) return; if (capNow[svc.id] === undefined) capNow[svc.id] = ((await host.status(svc.id)) || {}).cap || null; }), ]); if (seq !== paintSeq) return; // superseded // Mid-exchange (just came back from consent): that service's row shows a // loading state, not a flash of "Not connected". const pendingSvc = host.pending ? host.pending() : ""; list.textContent = ""; for (const svc of services) list.appendChild(buildRow(svc, connected.includes(svc.id), pendingSvc === svc.id && !connected.includes(svc.id))); } paint(); // shell.js fires this after a connect/disconnect completes (it carries a // human-readable note); repaint and surface the note inline (no toast). const onChange = (e) => { if (e.detail && typeof e.detail.text === "string") note.textContent = e.detail.text; busy = false; paint(); }; window.addEventListener("lchat:connected-changed", onChange); ctx.onCleanup(() => window.removeEventListener("lchat:connected-changed", onChange)); }, styles: ` .connect-app { max-width: 560px; margin: 0 auto; padding: 24px 20px; } .connect-h { margin: 0 0 6px; font-size: 20px; } .connect-sub { margin: 0 0 20px; color: var(--muted, #667); font-size: 14px; line-height: 1.5; } .connect-list { display: flex; flex-direction: column; gap: 12px; } .connect-item { padding: 14px 16px; border: 1px solid var(--line, #e3e3e8); border-radius: 12px; } .connect-row { display: flex; align-items: center; gap: 10px; } .connect-name { font-weight: 600; } .connect-cat { font-size: 12px; color: var(--muted, #889); text-transform: capitalize; } .connect-status { margin-left: auto; font-size: 13px; color: var(--muted, #889); } .connect-status.on { color: #1a8f4c; } .connect-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } .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-secondary { background: transparent; color: var(--accent, #2f6fed); border: 1px solid var(--line, #e3e3e8); } .connect-detail { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; } .connect-tokenrow { display: flex; gap: 8px; } .connect-input { flex: 1; min-width: 0; padding: 8px 10px; border: 1px solid var(--line, #e3e3e8); border-radius: 9px; font: inherit; background: transparent; color: inherit; } .connect-help { font-size: 13px; color: var(--muted, #667); } .connect-ops { font-size: 13px; line-height: 1.4; color: var(--muted, #667); } .connect-rownote { margin-top: 8px; font-size: 13px; color: var(--muted, #667); } .connect-rownote:empty { margin-top: 0; } .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); } .connect-cap { display: flex; align-items: center; gap: 6px; margin-top: 10px; } .connect-cap-label { font-size: 13px; color: var(--muted, #667); } .connect-cap-btn { padding: 5px 11px; border-radius: 999px; border: 1px solid var(--line, #e3e3e8); background: transparent; color: var(--muted, #667); font: inherit; font-size: 13px; cursor: pointer; } .connect-cap-btn.on { border-color: var(--accent, #2f6fed); color: var(--accent, #2f6fed); font-weight: 600; } `, });