eleven / messenger Public

Stripe: OAuth-ready manifest + dashboard app (reopen of #705) (#743)

* web+broker: choose a connection's access at connect time (read-only default)

Connecting a service in Integrations now asks what the connection may do:
read only (default) or read & write. The cap seals INSIDE the credential
blob (server never learns it); requestOp refuses to mint a grant beyond it
(capAllows, ui.js — the one testable rule), rotation preserves it, and the
catalog shows + flips it in place. OAuth manifests may declare scopes /
scopes_write so the cap also picks the provider-side scope set on the
authorize URL; the broker hands both lists through /config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* bot+web: research sessions — one tap lets Eleven read across connected services

'Which of my DNSimple domains point at GitHub Pages?' → the brain proposes a
service_research chip naming the services; the tap seals a ~10-min READ grant
per service to the bot; the sidecar answers in a bounded tool loop
(research.go) whose palette is exactly the granted manifests' read ops —
writes are not in the model's world here. Results ride fenced in tool_result
blocks; rotated token pairs hand back sealed to the member on <rid>.renew;
tokens never enter a prompt (pinned by TestResearchEndToEnd).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* integrations: Stripe goes OAuth-ready + a Stripe dashboard app

stripe.json: auth both (Connect OAuth authorize/token endpoints; scopes
read_only, scopes_write read_write — the connection cap picks which), a
viewer op so pasted restricted keys verify on save, and payouts/
subscriptions reads with pick projections; balance.get now keeps available
AND pending. New web/static/apps/stripe.js: a takeover dashboard (balance
tiles, charges, payouts, subscriptions) fed by requestOp like the GitHub
app. Broker /config test pins that manifest scope lists ride through.
Enabling OAuth later = set LCHAT_OAUTH_STRIPE_CLIENT_ID/_CLIENT_SECRET on
the broker; until then paste-a-key works today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 83fff12fba79cfd2da6b7bed4ec0e7c05560ea25 parent 24a7a85
6 files changed, +416 −5
  • broker/broker_test.go +39 −0
  • changelog/2026-07-11-stripe-app.md +7 −0
  • docs/integrations.md +7 −2
  • integrations/registry/manifests/stripe.json +65 −3
  • web/static/apps/stripe.js +297 −0
  • web/static/shell.js +1 −0
diff --git a/broker/broker_test.go b/broker/broker_test.go
index a31443c..925145b 100644
--- a/broker/broker_test.go
+++ b/broker/broker_test.go
@@ -40,6 +40,14 @@ func newTestBroker(t *testing.T, tokenURL string) *httptest.Server {
authorizeURL: "https://github.com/login/oauth/authorize",
tokenURL: tokenURL,
},
+ "stripe": {
+ service: "stripe",
+ clientID: "ca_cid",
+ clientSecret: "sk_sec",
+ redirectURI: "https://broker.example/oauth/stripe/callback",
+ authorizeURL: "https://connect.stripe.com/oauth/authorize",
+ tokenURL: tokenURL,
+ },
},
http: &http.Client{Timeout: 5 * time.Second},
perInstance: newRateLimiter(60, 10),
@@ -209,6 +217,37 @@ func TestMultiServiceExchangeAndConfig(t *testing.T) {
// /api/refresh rotates an expiring pair: grant_type=refresh_token with the
// service's client pair, and the WHOLE new pair (access + rotated refresh +
// expiry) comes back for the sidecar to reseal. Unauthenticated → 401.
+// A manifest that declares OAuth scope lists (stripe: read_only vs
+// read_write) gets them on /config — public data the client uses to put the
+// cap-matched scope set on the authorize URL. Scope-less services (dnsimple)
+// stay scope-less.
+func TestConfigCarriesManifestScopes(t *testing.T) {
+ ts := newTestBroker(t, "https://unused.example")
+ resp, err := http.Get(ts.URL + "/config?service=stripe")
+ if err != nil {
+ t.Fatalf("config: %v", err)
+ }
+ defer resp.Body.Close()
+ var c map[string]any
+ json.NewDecoder(resp.Body).Decode(&c)
+ scopes, _ := c["scopes"].([]any)
+ scopesWrite, _ := c["scopes_write"].([]any)
+ if len(scopes) != 1 || scopes[0] != "read_only" || len(scopesWrite) != 1 || scopesWrite[0] != "read_write" {
+ t.Fatalf("stripe scopes: %v / %v", c["scopes"], c["scopes_write"])
+ }
+
+ respD, err := http.Get(ts.URL + "/config?service=dnsimple")
+ if err != nil {
+ t.Fatalf("config: %v", err)
+ }
+ defer respD.Body.Close()
+ var d map[string]any
+ json.NewDecoder(respD.Body).Decode(&d)
+ if _, has := d["scopes"]; has {
+ t.Fatalf("dnsimple config grew scopes it never declared: %v", d)
+ }
+}
+
func TestRefresh(t *testing.T) {
var gotGrant, gotRefresh string
gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/changelog/2026-07-11-stripe-app.md b/changelog/2026-07-11-stripe-app.md
new file mode 100644
index 0000000..cff2a3a
--- /dev/null
+++ b/changelog/2026-07-11-stripe-app.md
@@ -0,0 +1,7 @@
+🤖 Stripe joins Integrations properly: connect your Stripe account — with
+one click on Stripe's own site, or by pasting a restricted key — choosing
+read-only or read & write access, and a **Stripe app** appears in + New
+chat: your balance, recent charges, payouts, and subscriptions in one little
+dashboard. You can also just ask Eleven ("how did sales do this week?") and
+let it take a read-only look. Your key is sealed in your browser; the server
+never sees it.
diff --git a/docs/integrations.md b/docs/integrations.md
index bd1def4..e4a3ef5 100644
--- a/docs/integrations.md
+++ b/docs/integrations.md
@@ -197,8 +197,13 @@ can do, growable by PR without touching this codebase. The pipeline:
idle) errors with "reconnect it in Integrations".
Out of scope, called out honestly: standing charter grants (above),
-registering the OAuth apps themselves (a human act per provider; Stripe and
-HubSpot manifests stay `pat` until theirs exist),
+registering the OAuth apps themselves (a human act per provider — the Stripe
+manifest is OAuth-ready with `read_only`/`read_write` scopes mapped to the
+connection cap; the broker offers its Connect button the moment
+`LCHAT_OAUTH_STRIPE_CLIENT_ID`/`_CLIENT_SECRET` land in its env, and until
+then the catalog offers paste-a-restricted-key alone. NB Stripe's OAuth
+`client_secret` is the platform's own secret key — treat its env value with
+that weight),
Disconnect-revokes-provider-side, native catalog UI (connected services
remain web-only), and the Node reference sidecar (`agent/`) neither executes
registry ops nor proposes `service_op` — the Go sidecar is the production
diff --git a/integrations/registry/manifests/stripe.json b/integrations/registry/manifests/stripe.json
index e6e4b3d..f8d0807 100644
--- a/integrations/registry/manifests/stripe.json
+++ b/integrations/registry/manifests/stripe.json
@@ -4,12 +4,34 @@
"name": "Stripe",
"category": "payments",
"auth": {
- "kind": "pat",
+ "kind": "both",
"base": "https://api.stripe.com",
"authorization": "Bearer",
- "pat_help": "https://dashboard.stripe.com/apikeys — create a RESTRICTED key with only the permissions you want reachable"
+ "pat_help": "https://dashboard.stripe.com/apikeys — create a RESTRICTED key with only the permissions you want reachable (include Account read so the connection can verify itself)",
+ "oauth": {
+ "authorize_url": "https://connect.stripe.com/oauth/authorize",
+ "token_url": "https://connect.stripe.com/oauth/token",
+ "scopes": ["read_only"],
+ "scopes_write": ["read_write"]
+ }
},
"ops": [
+ {
+ "id": "viewer",
+ "label": "Who am I connected as",
+ "access": "read",
+ "method": "GET",
+ "path": "/v1/account",
+ "pick": {
+ "fields": [
+ "id",
+ "email",
+ "business_profile.name",
+ "settings.dashboard.display_name",
+ "default_currency"
+ ]
+ }
+ },
{
"id": "balance.get",
"label": "Current balance",
@@ -17,7 +39,10 @@
"method": "GET",
"path": "/v1/balance",
"pick": {
- "root": "available"
+ "fields": [
+ "available",
+ "pending"
+ ]
}
},
{
@@ -33,11 +58,48 @@
"amount",
"currency",
"status",
+ "refunded",
"description",
"created"
]
}
},
+ {
+ "id": "payouts.list",
+ "label": "Recent payouts",
+ "access": "read",
+ "method": "GET",
+ "path": "/v1/payouts?limit=10",
+ "pick": {
+ "root": "data",
+ "fields": [
+ "id",
+ "amount",
+ "currency",
+ "status",
+ "arrival_date",
+ "created"
+ ]
+ }
+ },
+ {
+ "id": "subscriptions.list",
+ "label": "Recent subscriptions",
+ "access": "read",
+ "method": "GET",
+ "path": "/v1/subscriptions?limit=10&status=all",
+ "pick": {
+ "root": "data",
+ "fields": [
+ "id",
+ "status",
+ "customer",
+ "cancel_at_period_end",
+ "current_period_end",
+ "created"
+ ]
+ }
+ },
{
"id": "refund.create",
"label": "Refund a charge",
diff --git a/web/static/apps/stripe.js b/web/static/apps/stripe.js
new file mode 100644
index 0000000..d5faca4
--- /dev/null
+++ b/web/static/apps/stripe.js
@@ -0,0 +1,297 @@
+// The Stripe companion app (docs/integrations.md § companion apps). Once
+// you've connected Stripe in Integrations (OAuth or a restricted key), this
+// app installs: a small dashboard — balance, recent charges, payouts, and
+// subscriptions — as its own thread.
+//
+// Same rail as the GitHub app: NO Stripe sidecar. Every fetch goes through
+// ctx.host.connected.requestOp, which seals your credential to the instance's
+// existing bot for one read-scoped op; what comes back is bounded by the
+// stripe manifest's `pick` projections, folds into state as typed events, and
+// syncs across your devices via snapshots. The server relays ciphertext
+// throughout. It's a TAKEOVER app (no chatMessage): the dashboard IS the
+// thread, so money data never echoes into chat bubbles or notifications.
+import { defineApp } from "../apps.js";
+
+// Stripe amounts are integer minor units except in zero-decimal currencies.
+const ZERO_DECIMAL = new Set(["bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg", "rwf", "ugx", "vnd", "vuv", "xaf", "xof", "xpf"]);
+export function fmtMoney(amount, currency) {
+ if (typeof amount !== "number" || !currency) return "—";
+ const cur = String(currency).toLowerCase();
+ const value = ZERO_DECIMAL.has(cur) ? amount : amount / 100;
+ try {
+ return new Intl.NumberFormat(undefined, { style: "currency", currency: cur.toUpperCase() }).format(value);
+ } catch {
+ return `${value} ${cur.toUpperCase()}`;
+ }
+}
+
+// Results arrive pick-projected (flattened dot-path keys where nested) —
+// normalise once at fold time, drop anything malformed.
+const normMoneyRows = (list) =>
+ (Array.isArray(list) ? list : [])
+ .filter((r) => r && typeof r.amount === "number" && typeof r.currency === "string")
+ .map((r) => ({
+ id: typeof r.id === "string" ? r.id : "",
+ amount: r.amount,
+ currency: r.currency,
+ status: typeof r.status === "string" ? r.status : "",
+ refunded: !!r.refunded,
+ description: typeof r.description === "string" ? r.description : "",
+ created: typeof r.created === "number" ? r.created : 0,
+ arrival_date: typeof r.arrival_date === "number" ? r.arrival_date : 0,
+ }));
+
+const normBalance = (b) => {
+ const part = (list) =>
+ (Array.isArray(list) ? list : [])
+ .filter((e) => e && typeof e.amount === "number" && typeof e.currency === "string")
+ .map((e) => ({ amount: e.amount, currency: e.currency }));
+ return b && typeof b === "object" ? { available: part(b.available), pending: part(b.pending) } : { available: [], pending: [] };
+};
+
+const normSubs = (list) =>
+ (Array.isArray(list) ? list : [])
+ .filter((s) => s && typeof s.id === "string")
+ .map((s) => ({
+ id: s.id,
+ status: typeof s.status === "string" ? s.status : "",
+ customer: typeof s.customer === "string" ? s.customer : "",
+ ending: !!s.cancel_at_period_end,
+ period_end: typeof s.current_period_end === "number" ? s.current_period_end : 0,
+ }));
+
+// Unix-seconds → short local date ("12 Jul"); "" when absent.
+function day(sec) {
+ if (!sec) return "";
+ try {
+ return new Date(sec * 1000).toLocaleDateString(undefined, { day: "numeric", month: "short" });
+ } catch {
+ return "";
+ }
+}
+
+let busy = false;
+let autoLoading = false;
+
+export const app = defineApp({
+ name: "stripe",
+ title: "Stripe",
+ blurb: "Balance, charges & payouts",
+ glyph: "💳",
+ scope: "personal",
+ // Only listed in New chat once Stripe is linked (shell.js gates on this).
+ requiresService: "stripe",
+ initialState: () => ({ balance: null, charges: [], payouts: [], subs: [], fetchedAt: 0 }),
+
+ events: {
+ "stripe.balance": (s, e) => ({ ...s, balance: normBalance(e.balance), fetchedAt: e.ts || s.fetchedAt }),
+ "stripe.charges": (s, e) => (Array.isArray(e.charges) ? { ...s, charges: normMoneyRows(e.charges), fetchedAt: e.ts || s.fetchedAt } : s),
+ "stripe.payouts": (s, e) => (Array.isArray(e.payouts) ? { ...s, payouts: normMoneyRows(e.payouts), fetchedAt: e.ts || s.fetchedAt } : s),
+ "stripe.subs": (s, e) => (Array.isArray(e.subs) ? { ...s, subs: normSubs(e.subs), fetchedAt: e.ts || s.fetchedAt } : s),
+ },
+
+ styles: `
+.stp-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; }
+.stp-title { margin: 0; font-size: 18px; }
+.stp-sub { margin-left: auto; font-size: 12px; color: var(--muted); white-space: nowrap; }
+.stp-btn { 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); }
+.stp-btn:disabled { opacity: .6; cursor: default; }
+.stp-tiles { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 16px; }
+.stp-tile { flex: 1 1 150px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 12px; }
+.stp-tile-label { font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
+.stp-tile-value { margin-top: 4px; font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
+.stp-h { margin: 18px 0 6px; font-size: 13px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--muted); }
+.stp-scroll { overflow-x: auto; margin: 0 -4px; }
+.stp-table { width: 100%; border-collapse: collapse; font-size: 14px; }
+.stp-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; }
+.stp-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
+.stp-amount { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; font-weight: 500; }
+.stp-desc { color: var(--muted); overflow-wrap: anywhere; }
+.stp-when { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
+.stp-badge { display: inline-block; font-size: 10px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; border: 1px solid var(--border); border-radius: 999px; padding: 1px 7px; white-space: nowrap; }
+.stp-badge.ok { color: #1a8f4c; border-color: #1a8f4c66; }
+.stp-badge.warn { color: #b26a00; border-color: #b26a0066; }
+.stp-empty { color: var(--muted); padding: 8px 0; }
+.stp-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted); }
+`,
+
+ render(state, el, ctx) {
+ const host = ctx.host && ctx.host.connected;
+ state = state || { balance: null, charges: [], payouts: [], subs: [], fetchedAt: 0 };
+
+ const head = document.createElement("div");
+ head.className = "stp-head";
+ el.appendChild(head);
+
+ const title = document.createElement("h2");
+ title.className = "stp-title";
+ title.textContent = "Stripe";
+ const sub = document.createElement("span");
+ sub.className = "stp-sub";
+
+ const note = document.createElement("div");
+ note.className = "stp-note";
+
+ if (!host || !host.requestOp) {
+ head.appendChild(title);
+ const empty = document.createElement("div");
+ empty.className = "stp-empty";
+ empty.textContent = "Stripe needs the web app for now — open this chat in a web browser (connecting isn't supported in the app yet).";
+ el.append(empty);
+ return;
+ }
+
+ // One Refresh pulls all four reads; each folds as its own event so a slow
+ // or failing one never blanks the rest. Whole body under try/finally so a
+ // throw can never wedge the module-level busy flag (the github.js lesson).
+ const refresh = async () => {
+ if (busy) return;
+ busy = true;
+ const errors = [];
+ try {
+ note.textContent = "";
+ paint();
+ const pulls = [
+ ["balance.get", (d) => ctx.emit({ type: "stripe.balance", balance: d })],
+ ["charges.list", (d) => ctx.emit({ type: "stripe.charges", charges: d })],
+ ["payouts.list", (d) => ctx.emit({ type: "stripe.payouts", payouts: d })],
+ ["subscriptions.list", (d) => ctx.emit({ type: "stripe.subs", subs: d })],
+ ];
+ await Promise.all(
+ pulls.map(async ([op, emit]) => {
+ let res;
+ try {
+ res = await host.requestOp("stripe", op, {});
+ } catch (e) {
+ res = { ok: false, error: (e && e.message) || String(e) };
+ }
+ if (res && res.ok && typeof res.data === "string") {
+ try {
+ emit(JSON.parse(res.data));
+ return;
+ } catch {}
+ }
+ errors.push(`${op}: ${(res && res.error) || "no response"}`);
+ }),
+ );
+ } finally {
+ busy = false;
+ autoLoading = false;
+ }
+ if (errors.length) note.textContent = `Couldn't refresh everything — ${errors[0]}`;
+ paint();
+ };
+
+ const refreshBtn = document.createElement("button");
+ refreshBtn.type = "button";
+ refreshBtn.className = "stp-btn";
+ refreshBtn.textContent = "↻ Refresh";
+ refreshBtn.onclick = refresh;
+ head.append(title, sub, refreshBtn);
+
+ // ---- balance tiles ------------------------------------------------------
+ const tiles = document.createElement("div");
+ tiles.className = "stp-tiles";
+ const tile = (label, entries) => {
+ const t = document.createElement("div");
+ t.className = "stp-tile";
+ const l = document.createElement("div");
+ l.className = "stp-tile-label";
+ l.textContent = label;
+ const v = document.createElement("div");
+ v.className = "stp-tile-value";
+ v.textContent = entries && entries.length ? entries.map((e) => fmtMoney(e.amount, e.currency)).join(" · ") : "—";
+ t.append(l, v);
+ return t;
+ };
+ if (state.balance) {
+ tiles.append(tile("Available", state.balance.available), tile("Pending", state.balance.pending));
+ el.appendChild(tiles);
+ }
+
+ // ---- tables -------------------------------------------------------------
+ const section = (heading, rows, cols, rowCells) => {
+ const h = document.createElement("div");
+ h.className = "stp-h";
+ h.textContent = heading;
+ el.appendChild(h);
+ if (!rows.length) {
+ const empty = document.createElement("div");
+ empty.className = "stp-empty";
+ empty.textContent = state.fetchedAt ? "Nothing here." : "Not loaded yet — hit Refresh.";
+ el.appendChild(empty);
+ return;
+ }
+ const scroll = document.createElement("div");
+ scroll.className = "stp-scroll";
+ const table = document.createElement("table");
+ table.className = "stp-table";
+ const thead = document.createElement("thead");
+ const hr = document.createElement("tr");
+ for (const c of cols) {
+ const th = document.createElement("th");
+ th.textContent = c;
+ hr.appendChild(th);
+ }
+ thead.appendChild(hr);
+ table.appendChild(thead);
+ const tbody = document.createElement("tbody");
+ for (const r of rows) {
+ const tr = document.createElement("tr");
+ for (const cell of rowCells(r)) tr.appendChild(cell);
+ tbody.appendChild(tr);
+ }
+ table.appendChild(tbody);
+ scroll.appendChild(table);
+ el.appendChild(scroll);
+ };
+
+ const td = (text, cls) => {
+ const c = document.createElement("td");
+ if (cls) c.className = cls;
+ c.textContent = text;
+ return c;
+ };
+ const badge = (text, ok) => {
+ const c = document.createElement("td");
+ const b = document.createElement("span");
+ b.className = "stp-badge" + (ok === true ? " ok" : ok === false ? " warn" : "");
+ b.textContent = text || "—";
+ c.appendChild(b);
+ return c;
+ };
+
+ section("Recent charges", state.charges, ["Amount", "Status", "Description", "When"], (r) => [
+ td(fmtMoney(r.amount, r.currency), "stp-amount"),
+ badge(r.refunded ? "refunded" : r.status, r.refunded ? false : r.status === "succeeded"),
+ td(r.description || "—", "stp-desc"),
+ td(day(r.created), "stp-when"),
+ ]);
+ section("Payouts", state.payouts, ["Amount", "Status", "Arrives", "Created"], (r) => [
+ td(fmtMoney(r.amount, r.currency), "stp-amount"),
+ badge(r.status, r.status === "paid"),
+ td(day(r.arrival_date), "stp-when"),
+ td(day(r.created), "stp-when"),
+ ]);
+ section("Subscriptions", state.subs, ["Subscription", "Status", "Renews"], (r) => [
+ td(r.id, "stp-desc"),
+ badge(r.ending ? "ending" : r.status, r.ending ? false : r.status === "active"),
+ td(day(r.period_end), "stp-when"),
+ ]);
+
+ el.appendChild(note);
+
+ const paint = () => {
+ sub.textContent = busy ? "loading…" : state.fetchedAt ? `updated ${new Date(state.fetchedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "";
+ refreshBtn.disabled = busy;
+ };
+ paint();
+
+ // First open pulls once, automatically — after the surface is fully built
+ // (never mid-render; see the github.js temporal-dead-zone lesson).
+ if (!state.fetchedAt && !busy && !autoLoading) {
+ autoLoading = true;
+ refresh();
+ }
+ },
+});
diff --git a/web/static/shell.js b/web/static/shell.js
index cd274ca..79a4e2e 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -113,6 +113,7 @@ import "/static/apps/vault.js";
import "/static/apps/board.js";
import "/static/apps/connect.js";
import "/static/apps/dnsimple.js";
+import "/static/apps/stripe.js";
import "/static/apps/github.js";
import "/static/apps/emoji.js"; // /emoji — the command-panel reference app
import "/static/apps/gif.js"; // /gif — Giphy search in a command panel (bring-your-own key; posts via ctx.host.gif)