eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1// Integrations — the `connect` app (prototype) — docs/connected-services.md.
2//
3// A registry-driven catalog for linking third-party accounts so a bot can act
4// for you. Each service comes from the integrations registry (one reviewed
5// manifest per service, served at /api/integrations) and offers two ways in:
6// authorize on the provider's own site (OAuth PKCE — no key pasted, no client
7// secret exists), or paste a scoped token. Either way the credential is sealed
8// in your browser and the server only ever holds an opaque, client-encrypted
9// blob. The privileged crypto lives in shell.js and is reached via
10// ctx.host.connected (an app can't touch the session keypair, roster, or fetch).
11//
12// Command: `/connect` opens this surface. (Listing domains is not a generic
13// verb owned here — it belongs to the service you connected: the DNSimple app
14// owns a context-local `/list` that only works inside its own thread.)
15import { defineApp, namespace, appByName, openAppThread } from "../apps.js";
16import { LOGO_PLUG } from "../logos.js";
17
18// One declaration of the app's prefix, reused everywhere: N.cls("card") → the
19// "connect-card" CSS class. Change "connect" here and every string below follows.
20const N = namespace("connect");
21
22const unavailable = (ctx) => ctx.reply({ text: "Connecting services isn't available here yet." });
23
24// When the registry can't be fetched, render what the surface always offered —
25// the DNSimple row — so nothing regresses on an old server or a flaky load.
26const FALLBACK_CATALOG = [
27 {
28 id: "dnsimple",
29 name: "DNSimple",
30 category: "domains",
31 auth: {
32 kind: "both",
33 pat_help: "https://support.dnsimple.com/articles/api-access-token/ — create a token scoped to the domains you want managed",
34 },
35 ops: [],
36 },
37];
38
39// The first http(s) URL inside a manifest's pat_help (the field is prose that
40// leads with the link). Only http(s) ever becomes an href.
41function patHelpURL(svc) {
42 const m = /https?:\/\/[^\s]+/.exec((svc.auth && svc.auth.pat_help) || "");
43 return m ? m[0] : "";
44}
45
46// One muted line of what connecting allows, straight from the manifest's closed
47// op list: "Reads: List domains, Check availability · Writes (always
48// confirmed): Register a domain".
49function opsSummary(svc) {
50 const label = (o) => o.label || o.id;
51 const reads = (svc.ops || []).filter((o) => o.access === "read").map(label);
52 const writes = (svc.ops || []).filter((o) => o.access === "write").map(label);
53 const parts = [];
54 if (reads.length) parts.push(`Reads: ${reads.join(", ")}`);
55 if (writes.length) parts.push(`Writes (always confirmed): ${writes.join(", ")}`);
56 return parts.join(" · ");
57}
58
59export const app = defineApp({
60 name: "connect",
61 title: "Integrations",
62 // A one-line blurb shown as the sidebar subtitle before the thread has any
63 // activity (mirrored natively in ThreadStore.appDescriptions — keep in sync).
64 blurb: "Connect third-party services",
65 glyph: "🔌",
66 mark: "plug", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md)
67 logo: LOGO_PLUG,
68 places: ["you"],
69 placeNote: "Connections use your own account credentials — each person links their own.",
70 initialState: () => ({}),
71 events: {},
72 commands: {
73 "/connect": {
74 desc: "Connect a third-party service",
75 args: "",
76 run: (_args, ctx) => {
77 if (!ctx.host || !ctx.host.connected) return unavailable(ctx);
78 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 }] });
79 },
80 },
81 },
82
83 render(_state, el, ctx) {
84 el.textContent = "";
85 const wrap = document.createElement("div");
86 wrap.className = N.cls("app");
87
88 const h = document.createElement("h2");
89 h.className = N.cls("h");
90 h.textContent = "Integrations";
91 wrap.appendChild(h);
92
93 const sub = document.createElement("p");
94 sub.className = N.cls("sub");
95 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.";
96 wrap.appendChild(sub);
97
98 const list = document.createElement("div");
99 list.className = N.cls("list");
100 wrap.appendChild(list);
101
102 const note = document.createElement("div");
103 note.className = N.cls("note");
104 wrap.appendChild(note);
105 el.appendChild(wrap);
106
107 const host = ctx.host && ctx.host.connected;
108 let busy = false; // an OAuth start in flight
109 let catalog; // undefined until fetched; null/[] = fetch failed → fallback
110
111 // Whether a service can actually start an OAuth flow here (manifest says
112 // oauth-capable AND the broker has an app registered). null = unknown yet;
113 // the OAuth button renders only once this confirms, so a service whose app
114 // isn't registered offers paste-a-token alone instead of a dead button.
115 const oauthOK = {};
116 const oauthCapable = (svc) => {
117 const kind = (svc.auth && svc.auth.kind) || "";
118 return kind === "oauth" || kind === "both";
119 };
120
121 // The access cap: what this connection is ALLOWED to do, chosen by the
122 // member. capChoice is the pre-connect pick (default read-only — least
123 // privilege unless you say otherwise); capNow is the cap read back off a
124 // connected credential (undefined = a legacy connection from before caps,
125 // which stays unrestricted until the member sets one).
126 const capChoice = {};
127 const capNow = {};
128 const capLabel = (cap) => (cap === "read" ? "read only" : "read & write");
129
130 // The pre-connect access picker: two mutually-exclusive options rendered
131 // as a small segmented row. Applies to BOTH connect paths (OAuth and
132 // paste-a-token) — the choice is about the connection, not the mechanism.
133 function buildCapPicker(svc) {
134 const wrap = document.createElement("div");
135 wrap.className = "connect-cap";
136 wrap.setAttribute("role", "radiogroup");
137 wrap.setAttribute("aria-label", `${svc.name || svc.id} access`);
138 const lab = document.createElement("span");
139 lab.className = "connect-cap-label";
140 lab.textContent = "Allow:";
141 wrap.appendChild(lab);
142 const current = () => capChoice[svc.id] || "read";
143 for (const [cap, text] of [["read", "Read only"], ["write", "Read & write"]]) {
144 const b = document.createElement("button");
145 b.type = "button";
146 b.className = "connect-cap-btn" + (current() === cap ? " on" : "");
147 b.setAttribute("role", "radio");
148 b.setAttribute("aria-checked", String(current() === cap));
149 b.textContent = text;
150 b.onclick = () => {
151 capChoice[svc.id] = cap;
152 for (const el of wrap.querySelectorAll(".connect-cap-btn")) {
153 const isOn = el === b;
154 el.classList.toggle("on", isOn);
155 el.setAttribute("aria-checked", String(isOn));
156 }
157 };
158 wrap.appendChild(b);
159 }
160 return wrap;
161 }
162
163 // One catalog row: name · category · status, then the action buttons and
164 // the (collapsed) paste-a-token detail. All text via textContent.
165 function buildRow(svc, on, pending) {
166 const item = document.createElement("div");
167 item.className = "connect-item";
168
169 const row = document.createElement("div");
170 row.className = "connect-row";
171 const name = document.createElement("span");
172 name.className = "connect-name";
173 name.textContent = svc.name || svc.id;
174 const cat = document.createElement("span");
175 cat.className = "connect-cat";
176 cat.textContent = svc.category || "";
177 const status = document.createElement("span");
178 status.className = "connect-status";
179 const capText = on && capNow[svc.id] ? ` · ${capLabel(capNow[svc.id])}` : "";
180 status.textContent = pending ? "Connecting…" : on ? `Connected ✓${capText}` : "Not connected";
181 status.classList.toggle("on", on && !pending);
182 row.append(name, cat, status);
183 item.appendChild(row);
184
185 // Choose the connection's access BEFORE connecting — the cap seals into
186 // the stored credential and grants can never exceed it.
187 if (!on && !pending) item.appendChild(buildCapPicker(svc));
188
189 const actions = document.createElement("div");
190 actions.className = "connect-actions";
191 item.appendChild(actions);
192
193 const detail = document.createElement("div");
194 detail.className = "connect-detail";
195 detail.hidden = true;
196 item.appendChild(detail);
197
198 const rowNote = document.createElement("div");
199 rowNote.className = "connect-rownote";
200 item.appendChild(rowNote);
201
202 // The golden path: authorize on the provider's own site whenever the
203 // broker has an OAuth app for this service (oauthOK, confirmed against
204 // the broker's config) — pasting a token is the fallback below.
205 if (!on && oauthOK[svc.id]) {
206 const oauthBtn = document.createElement("button");
207 oauthBtn.type = "button";
208 oauthBtn.className = "connect-btn";
209 oauthBtn.textContent = busy || pending ? "Connecting…" : `Connect with ${svc.name || svc.id}`;
210 oauthBtn.disabled = busy || pending;
211 oauthBtn.onclick = async () => {
212 if (busy || pending) return;
213 note.textContent = "";
214 busy = true;
215 paint();
216 const res = await host.startOAuth(svc.id, capChoice[svc.id] || "read");
217 busy = false;
218 paint();
219 if (!res.ok) note.textContent = res.error || "Couldn't start connecting.";
220 };
221 actions.appendChild(oauthBtn);
222 }
223
224 // Connected, and the service has a companion app (DNSimple's domains,
225 // GitHub's repos): the primary action becomes going there — the whole
226 // point of connecting. Created on first use, like any personal app.
227 if (on && appByName(svc.id)) {
228 const openBtn = document.createElement("button");
229 openBtn.type = "button";
230 openBtn.className = "connect-btn";
231 openBtn.textContent = `Open ${svc.name || svc.id}`;
232 openBtn.onclick = () => openAppThread(svc.id);
233 actions.appendChild(openBtn);
234 }
235
236 // Every service takes a pasted token — sealed to your own key by the
237 // host bridge before it leaves this page (the same storage path the
238 // OAuth completion uses).
239 const pasteBtn = document.createElement("button");
240 pasteBtn.type = "button";
241 pasteBtn.className = "connect-btn connect-secondary";
242 pasteBtn.textContent = on ? "Replace token" : "Paste a token";
243 pasteBtn.onclick = () => {
244 detail.hidden = !detail.hidden;
245 if (!detail.hidden) input.focus();
246 };
247 actions.appendChild(pasteBtn);
248
249 const tokenRow = document.createElement("div");
250 tokenRow.className = "connect-tokenrow";
251 const input = document.createElement("input");
252 input.type = "password";
253 input.className = "connect-input";
254 input.placeholder = `Paste your ${svc.name || svc.id} token`;
255 input.autocomplete = "off";
256 const save = document.createElement("button");
257 save.type = "button";
258 save.className = "connect-btn";
259 save.textContent = "Save";
260 save.onclick = async () => {
261 const v = input.value.trim();
262 if (!v) {
263 rowNote.textContent = "Paste a token first.";
264 return;
265 }
266 rowNote.textContent = "";
267 save.disabled = true;
268 save.textContent = "Saving…";
269 const res = await host.store(svc.id, v, capChoice[svc.id] || "read");
270 save.disabled = false;
271 save.textContent = "Save";
272 if (!res.ok) {
273 rowNote.textContent = res.error || "Couldn't save the token.";
274 return;
275 }
276 input.value = "";
277 note.textContent = `${svc.name || svc.id} connected — your token was sealed in your browser; the server can't read it.`;
278 paint();
279 // Best-effort verification: when the manifest declares a "viewer" read
280 // op (whoami-style), run it once through the bot so a typo'd token
281 // surfaces NOW, not on first real use. The token stays saved either
282 // way (they can Replace it); no bot online just means "couldn't check".
283 const viewer = (svc.ops || []).find((o) => o.id === "viewer" && o.access === "read");
284 if (viewer && host.requestOp) {
285 const check = await host.requestOp(svc.id, "viewer", {});
286 let who = "";
287 if (check.ok && typeof check.data === "string") {
288 try {
289 const d = JSON.parse(check.data);
290 who = d && (d.login || d.name) ? ` as ${d.login || d.name}` : "";
291 } catch {}
292 }
293 note.textContent = check.ok
294 ? `${svc.name || svc.id} connected${who} ✓ — your token was sealed in your browser; the server can't read it.`
295 : `${svc.name || svc.id} token saved, but it didn't verify: ${check.error || "no response"}. Double-check the token (Replace token to swap it).`;
296 }
297 };
298 tokenRow.append(input, save);
299 detail.appendChild(tokenRow);
300
301 const helpURL = patHelpURL(svc);
302 if (helpURL) {
303 const help = document.createElement("a");
304 help.className = "connect-help";
305 help.href = helpURL;
306 help.target = "_blank";
307 help.rel = "noopener noreferrer";
308 help.textContent = "Create a scoped token";
309 detail.appendChild(help);
310 }
311 const sum = opsSummary(svc);
312 if (sum) {
313 const ops = document.createElement("div");
314 ops.className = "connect-ops";
315 ops.textContent = sum;
316 detail.appendChild(ops);
317 }
318
319 if (on && host.setCap) {
320 // Flip the connection's cap in place (re-seals the credential — needs
321 // no re-paste and no provider round-trip). Loosening to read & write
322 // is an explicit tap; note that an OAuth token minted under read-only
323 // provider scopes stays read-limited at the provider regardless.
324 const flip = capNow[svc.id] === "read" ? "write" : "read";
325 const capBtn = document.createElement("button");
326 capBtn.type = "button";
327 capBtn.className = "connect-btn connect-secondary";
328 capBtn.textContent = flip === "write" ? "Allow writes" : "Make read-only";
329 capBtn.onclick = async () => {
330 capBtn.disabled = true;
331 const res = await host.setCap(svc.id, flip);
332 capBtn.disabled = false;
333 if (!res.ok) {
334 rowNote.textContent = res.error || "Couldn't change access.";
335 return;
336 }
337 capNow[svc.id] = flip;
338 note.textContent = `${svc.name || svc.id} is now ${capLabel(flip)}.`;
339 paint();
340 };
341 actions.appendChild(capBtn);
342 }
343
344 if (on) {
345 const disc = document.createElement("button");
346 disc.type = "button";
347 disc.className = "connect-btn connect-cancel";
348 disc.textContent = "Disconnect";
349 disc.onclick = () => {
350 // Confirm first — disconnecting removes the stored token. Inline (no
351 // floating dialog), per the house style: the prompt occupies layout.
352 rowNote.textContent = "";
353 const confirm = document.createElement("div");
354 confirm.className = N.cls("confirm");
355 const q = document.createElement("span");
356 q.textContent = `Disconnect ${svc.name || svc.id}? Your saved token will be removed from this server.`;
357 const yes = document.createElement("button");
358 yes.type = "button";
359 yes.className = `${N.cls("btn")} ${N.cls("danger")}`;
360 yes.textContent = "Disconnect";
361 yes.onclick = () => host.disconnect(svc.id); // onChange repaints + notes on completion
362 const cancel = document.createElement("button");
363 cancel.type = "button";
364 cancel.className = `${N.cls("btn")} ${N.cls("cancel")}`;
365 cancel.textContent = "Cancel";
366 cancel.onclick = () => (rowNote.textContent = "");
367 confirm.append(q, yes, cancel);
368 rowNote.appendChild(confirm);
369 };
370 actions.appendChild(disc);
371 }
372
373 return item;
374 }
375
376 // Repaints rebuild the list; a sequence counter keeps an older in-flight
377 // paint from appending duplicate rows over a newer one.
378 let paintSeq = 0;
379 async function paint() {
380 const seq = ++paintSeq;
381 if (!host) {
382 list.textContent = "";
383 const p = document.createElement("div");
384 p.className = "connect-ops";
385 p.textContent = "Connecting services isn't available here yet.";
386 list.appendChild(p);
387 return;
388 }
389 if (catalog === undefined) catalog = host.catalog ? await host.catalog() : null;
390 const connected = await host.list();
391 const services = catalog && catalog.length ? catalog : FALLBACK_CATALOG;
392 // Resolve which oauth-capable services the broker actually offers (each
393 // answer is cached by the bridge, so repaints are cheap) — and warm the
394 // config so a Connect click can open the consent page directly. Connected
395 // services also read back their access cap for the status line.
396 await Promise.all([
397 ...services.filter(oauthCapable).map(async (svc) => {
398 if (oauthOK[svc.id] === undefined && host.oauthReady) oauthOK[svc.id] = await host.oauthReady(svc.id);
399 }),
400 ...services.map(async (svc) => {
401 if (!connected.includes(svc.id) || !host.status) return;
402 if (capNow[svc.id] === undefined) capNow[svc.id] = ((await host.status(svc.id)) || {}).cap || null;
403 }),
404 ]);
405 if (seq !== paintSeq) return; // superseded
406 // Mid-exchange (just came back from consent): that service's row shows a
407 // loading state, not a flash of "Not connected".
408 const pendingSvc = host.pending ? host.pending() : "";
409 list.textContent = "";
410 for (const svc of services) list.appendChild(buildRow(svc, connected.includes(svc.id), pendingSvc === svc.id && !connected.includes(svc.id)));
411 }
412 paint();
413
414 // shell.js fires this after a connect/disconnect completes (it carries a
415 // human-readable note); repaint and surface the note inline (no toast).
416 const onChange = (e) => {
417 if (e.detail && typeof e.detail.text === "string") note.textContent = e.detail.text;
418 busy = false;
419 paint();
420 };
421 window.addEventListener("lchat:connected-changed", onChange);
422 ctx.onCleanup(() => window.removeEventListener("lchat:connected-changed", onChange));
423 },
424
425 styles: `
426 .connect-app { max-width: 560px; margin: 0 auto; padding: 24px 20px; }
427 .connect-h { margin: 0 0 6px; font-size: 20px; }
428 .connect-sub { margin: 0 0 20px; color: var(--muted, #667); font-size: 14px; line-height: 1.5; }
429 .connect-list { display: flex; flex-direction: column; gap: 12px; }
430 .connect-item { padding: 14px 16px; border: 1px solid var(--line, #e3e3e8); border-radius: 12px; }
431 .connect-row { display: flex; align-items: center; gap: 10px; }
432 .connect-name { font-weight: 600; }
433 .connect-cat { font-size: 12px; color: var(--muted, #889); text-transform: capitalize; }
434 .connect-status { margin-left: auto; font-size: 13px; color: var(--muted, #889); }
435 .connect-status.on { color: #1a8f4c; }
436 .connect-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
437 .connect-btn { padding: 8px 14px; border-radius: 9px; border: 0; background: var(--accent, #2f6fed); color: #fff; font: inherit; font-weight: 600; cursor: pointer; }
438 .connect-btn:disabled { opacity: .6; cursor: default; }
439 .connect-secondary { background: transparent; color: var(--accent, #2f6fed); border: 1px solid var(--line, #e3e3e8); }
440 .connect-detail { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
441 .connect-tokenrow { display: flex; gap: 8px; }
442 .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; }
443 .connect-help { font-size: 13px; color: var(--muted, #667); }
444 .connect-ops { font-size: 13px; line-height: 1.4; color: var(--muted, #667); }
445 .connect-rownote { margin-top: 8px; font-size: 13px; color: var(--muted, #667); }
446 .connect-rownote:empty { margin-top: 0; }
447 .connect-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted, #667); }
448 .connect-confirm { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
449 .connect-confirm span { flex: 1 1 200px; color: var(--ink, #222); }
450 .connect-danger { background: #d64545; }
451 .connect-cancel { background: transparent; color: var(--muted, #667); border: 1px solid var(--line, #e3e3e8); }
452 .connect-cap { display: flex; align-items: center; gap: 6px; margin-top: 10px; }
453 .connect-cap-label { font-size: 13px; color: var(--muted, #667); }
454 .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; }
455 .connect-cap-btn.on { border-color: var(--accent, #2f6fed); color: var(--accent, #2f6fed); font-weight: 600; }
456 `,
457});
458