web+broker: choose a connection's access at connect time (read-only default) (#702)
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>
9 files changed,
+234
−27
broker/main.go+15 −2changelog/2026-07-11-connect-access-choice.md+7 −0docs/integrations.md+14 −3integrations/registry.go+5 −0integrations/registry_test.go+15 −0test/ui.test.mjs+15 −0web/static/apps/connect.js+88 −7web/static/shell.js+64 −15web/static/ui.js+11 −0
diff --git a/broker/main.go b/broker/main.go| index c81d50c..cf4df48 100644 |
| --- a/broker/main.go |
| +++ b/broker/main.go |
| @@ -234,12 +234,25 @@ func (s *Server) configHandler(w http.ResponseWriter, r *http.Request) { |
| http.Error(w, "unknown service", http.StatusNotFound) |
| return |
| } |
| - writeJSON(w, http.StatusOK, map[string]any{ |
| + out := map[string]any{ |
| "service": app.service, |
| "client_id": app.clientID, |
| "authorize_url": app.authorizeURL, |
| "redirect_uri": app.redirectURI, |
| - }) |
| + } |
| + // Manifest-declared provider scopes ride along (public data), so the client |
| + // can put the cap-matched scope set on the authorize URL: scopes for a |
| + // read-only connection, scopes_write for read & write. Absent for services |
| + // that scope elsewhere (GitHub Apps at install; DNSimple not at all). |
| + if m := integrations.MustRegistry().Get(app.service); m != nil && m.Auth.OAuth != nil { |
| + if len(m.Auth.OAuth.Scopes) > 0 { |
| + out["scopes"] = m.Auth.OAuth.Scopes |
| + } |
| + if len(m.Auth.OAuth.ScopesWrite) > 0 { |
| + out["scopes_write"] = m.Auth.OAuth.ScopesWrite |
| + } |
| + } |
| + writeJSON(w, http.StatusOK, out) |
| } |
| // registryHandler serves the embedded integrations registry. It is public, |
diff --git a/changelog/2026-07-11-connect-access-choice.md b/changelog/2026-07-11-connect-access-choice.md| new file mode 100644 |
| index 0000000..5bf1901 |
| --- /dev/null |
| +++ b/changelog/2026-07-11-connect-access-choice.md |
| @@ -0,0 +1,7 @@ |
| +🤖 When you connect a service in Integrations — GitHub, DNSimple, and |
| +friends — you now choose what that connection is allowed to do: **read |
| +only** (the default) or **read & write**. The choice is sealed into your |
| +connection with the same lock only you hold, so the server never learns it, |
| +and it's a hard ceiling: a read-only connection can never be asked to write, |
| +by a bot or anyone else. You can change a connection's access any time from |
| +Integrations. |
diff --git a/docs/integrations.md b/docs/integrations.md| index 7c21d35..62693ef 100644 |
| --- a/docs/integrations.md |
| +++ b/docs/integrations.md |
| @@ -45,15 +45,26 @@ grant is sealed to exactly one bot's public key, **access is per-bot by |
| construction**: seal Eleven a write grant for DNSimple and your reporting bot |
| a read-only one, and the executor enforces the difference — a read grant |
| cannot run a write op, whatever the model asks for. Least privilege stacks in |
| -three layers: |
| +four layers: |
| 1. **The token itself** — mint scoped PATs (GitHub fine-grained, Stripe |
| restricted keys, DNSimple domain-scoped); the manifest's `pat_help` points |
| at each service's scoping page, and the catalog verifies a pasted token |
| immediately through the manifest's `viewer` op when one exists. |
| -2. **The grant** — read/write + expiry, sealed per bot, minted per operation |
| +2. **The connection cap** — chosen by the member AT CONNECT TIME (read-only |
| + is the default; read & write is the explicit choice), sealed inside the |
| + credential blob so the server never learns it. The member's client refuses |
| + to mint any grant beyond the cap (`capAllows`, `ui.js`) — a read-only |
| + connection cannot produce a write grant whatever a chip or proposal asks — |
| + and for OAuth services whose manifests declare `scopes` / `scopes_write`, |
| + the cap also picks the provider-side scope set on the authorize URL, so a |
| + read-only connection's token is born read-only at the provider too. The |
| + cap is changeable later in Integrations (re-sealing the blob; the member's |
| + presence is the consent). Connections stored before caps existed carry no |
| + cap and behave as before. |
| +3. **The grant** — read/write + expiry, sealed per bot, minted per operation |
| (the apps and chips use one-shot ~2-minute grants). |
| -3. **The tap** — every write op renders its `confirm` line as a consent chip |
| +4. **The tap** — every write op renders its `confirm` line as a consent chip |
| ("Register blip.com? This buys the domain and costs real money."); |
| nothing irreversible runs from model output alone. |
diff --git a/integrations/registry.go b/integrations/registry.go| index def14dd..106c244 100644 |
| --- a/integrations/registry.go |
| +++ b/integrations/registry.go |
| @@ -51,6 +51,11 @@ type OAuthInfo struct { |
| AuthorizeURL string `json:"authorize_url"` |
| TokenURL string `json:"token_url"` // the code→token exchange endpoint (broker-only) |
| Scopes []string `json:"scopes"` |
| + // ScopesWrite replaces Scopes on the authorize URL when the member chose a |
| + // read & write connection at connect time — a read-only connection's |
| + // provider token is then minted under read-only provider scopes, not merely |
| + // capped client-side. Absent = Scopes serves both caps. |
| + ScopesWrite []string `json:"scopes_write"` |
| } |
| // Op is one declared operation. Everything a call can vary is a named string |
diff --git a/integrations/registry_test.go b/integrations/registry_test.go| index 50f0228..40ccc14 100644 |
| --- a/integrations/registry_test.go |
| +++ b/integrations/registry_test.go |
| @@ -78,6 +78,21 @@ func TestParseValidManifest(t *testing.T) { |
| } |
| } |
| +func TestParseOAuthScopeLists(t *testing.T) { |
| + // scopes / scopes_write are optional public data the broker hands the |
| + // client for the authorize URL; parsing must carry both through. |
| + raw := strings.Replace(validManifest, `"kind": "pat"`, |
| + `"kind": "both", "oauth": { "authorize_url": "https://svc.example/oauth", "token_url": "https://api.svc.example/token", "scopes": ["read"], "scopes_write": ["read", "write"] }`, 1) |
| + m, err := Parse([]byte(raw)) |
| + if err != nil { |
| + t.Fatalf("oauth manifest rejected: %v", err) |
| + } |
| + o := m.Auth.OAuth |
| + if o == nil || len(o.Scopes) != 1 || len(o.ScopesWrite) != 2 || o.ScopesWrite[1] != "write" { |
| + t.Fatalf("oauth scopes: %+v", o) |
| + } |
| +} |
| + |
| func TestParseRejectsInvalidManifests(t *testing.T) { |
| // Each case is the valid manifest with one rule broken; every one must be |
| // rejected outright (fail closed — no partial load). |
diff --git a/test/ui.test.mjs b/test/ui.test.mjs| index 3599690..c661f7e 100644 |
| --- a/test/ui.test.mjs |
| +++ b/test/ui.test.mjs |
| @@ -23,6 +23,7 @@ import { |
| DEFAULT_THEME, |
| DEFAULT_APPEARANCE, |
| sanitizeActions, |
| + capAllows, |
| normalizeTextSize, |
| TEXT_SIZES, |
| DEFAULT_TEXT_SIZE, |
| @@ -381,5 +382,19 @@ await test("translateMentions: rewrites published @names to the viewer's, same b |
| assert.equal(translateMentions("", renames), ""); |
| }); |
| +await test("capAllows: a connection's cap is the grant ceiling", () => { |
| + // Reads pass under every cap (and under no cap at all). |
| + assert.equal(capAllows("read", "read"), true); |
| + assert.equal(capAllows("write", "read"), true); |
| + assert.equal(capAllows(undefined, "read"), true); |
| + assert.equal(capAllows("read", undefined), true); // access defaults to read |
| + // Writes need a read+write connection… |
| + assert.equal(capAllows("write", "write"), true); |
| + assert.equal(capAllows("read", "write"), false); |
| + // …except legacy cap-less connections, which keep their old behaviour. |
| + assert.equal(capAllows(undefined, "write"), true); |
| + assert.equal(capAllows(null, "write"), true); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/web/static/apps/connect.js b/web/static/apps/connect.js| index 3c09594..4116b69 100644 |
| --- a/web/static/apps/connect.js |
| +++ b/web/static/apps/connect.js |
| @@ -110,6 +110,48 @@ export const app = defineApp({ |
| 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) { |
| @@ -126,11 +168,16 @@ export const app = defineApp({ |
| cat.textContent = svc.category || ""; |
| const status = document.createElement("span"); |
| status.className = "connect-status"; |
| - status.textContent = pending ? "Connecting…" : on ? "Connected ✓" : "Not connected"; |
| + 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); |
| @@ -158,7 +205,7 @@ export const app = defineApp({ |
| note.textContent = ""; |
| busy = true; |
| paint(); |
| - const res = await host.startOAuth(svc.id); |
| + 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."; |
| @@ -211,7 +258,7 @@ export const app = defineApp({ |
| rowNote.textContent = ""; |
| save.disabled = true; |
| save.textContent = "Saving…"; |
| - const res = await host.store(svc.id, v); |
| + const res = await host.store(svc.id, v, capChoice[svc.id] || "read"); |
| save.disabled = false; |
| save.textContent = "Save"; |
| if (!res.ok) { |
| @@ -261,6 +308,31 @@ export const app = defineApp({ |
| 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"; |
| @@ -311,12 +383,17 @@ export const app = defineApp({ |
| 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. |
| - await Promise.all( |
| - services.filter(oauthCapable).map(async (svc) => { |
| + // 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". |
| @@ -364,5 +441,9 @@ export const app = defineApp({ |
| .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; } |
| `, |
| }); |
diff --git a/web/static/shell.js b/web/static/shell.js| index 7825f9e..8d9ab57 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -45,6 +45,7 @@ import { |
| displayNameFor, |
| renameNotice, |
| translateMentions, |
| + capAllows, |
| } from "/static/ui.js"; |
| import { richTokens, blockTokens, firstLink, typingSummary, replySnippet, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId } from "/static/format.js"; |
| import { emojify, emojiMatches } from "/static/emoji.js"; |
| @@ -3154,16 +3155,26 @@ async function loadOAuthCfg(service) { |
| // broker relays the code back here, plus a nonce for CSRF matching; we stash the |
| // pending {service,state} in sessionStorage because module state is lost across |
| // the full-tab round-trip. The browser never holds a secret. |
| -function openAuthorize(service, cfg) { |
| +function openAuthorize(service, cfg, cap) { |
| if (!session.userPriv) return { ok: false, error: "connecting services isn't supported in the native app yet" }; |
| if (!cfg || !cfg.client_id || !cfg.authorize_url || !cfg.redirect_uri) |
| return { ok: false, error: `${service} isn't set up for OAuth on this server yet` }; |
| const nonce = bytesToB64url(crypto.getRandomValues(new Uint8Array(16))); |
| const state = bytesToB64url(new TextEncoder().encode(location.origin)) + "." + nonce; |
| - sessionStorage.setItem("lchat_oauth", JSON.stringify({ service, state })); |
| - const url = |
| + // The access cap the member chose rides the round-trip so the completion can |
| + // seal it into the stored credential — least privilege is picked BEFORE the |
| + // provider is ever visited. |
| + sessionStorage.setItem("lchat_oauth", JSON.stringify({ service, state, cap: cap || "read" })); |
| + let url = |
| `${cfg.authorize_url}?response_type=code&client_id=${encodeURIComponent(cfg.client_id)}` + |
| `&redirect_uri=${encodeURIComponent(cfg.redirect_uri)}&state=${encodeURIComponent(state)}`; |
| + // Providers whose manifests declare OAuth scopes get them on the authorize |
| + // URL, matched to the chosen cap (scopes_write only when the member asked |
| + // for read & write) — the provider-side token is then no broader than the |
| + // cap it will live under. Scope-less manifests (DNSimple, GitHub Apps — |
| + // which scope at install time) send no scope param, exactly as before. |
| + const scopes = cap === "write" && Array.isArray(cfg.scopes_write) && cfg.scopes_write.length ? cfg.scopes_write : cfg.scopes; |
| + if (Array.isArray(scopes) && scopes.length) url += `&scope=${encodeURIComponent(scopes.join(" "))}`; |
| location.assign(url); |
| return { ok: true }; |
| } |
| @@ -3218,15 +3229,19 @@ async function completeOAuth(pend, code) { |
| const opened = await openSealed(sealed, session.userPriv); |
| // Expiring-token providers hand back {secret, refresh, expires_at}; store |
| // the whole pair (absent fields drop out of the JSON) so ops can rotate it. |
| - await storeCredential(pend.service, opened); |
| + // The access cap chosen before the consent trip rides into the credential. |
| + await storeCredential(pend.service, { ...opened, cap: pend.cap }); |
| } |
| // Seal a credential object to the member's OWN key and store the opaque blob — |
| // the storage half of connecting, shared by the OAuth completion and the |
| -// renewed-pair path (a bot rotating an expired token mid-op). |
| +// renewed-pair path (a bot rotating an expired token mid-op). `cap` is the |
| +// member's connect-time access choice ("read" | "write"); it lives inside the |
| +// sealed blob, so the server never learns it and only this member's devices |
| +// can read or change it. |
| async function storeCredential(service, cred) { |
| const me = roster.find((e) => e.user_id === myUserId); |
| - const blob = await sealForPub(me.public_key, { secret: cred.secret, refresh: cred.refresh, expires_at: cred.expires_at }); |
| + const blob = await sealForPub(me.public_key, { secret: cred.secret, refresh: cred.refresh, expires_at: cred.expires_at, cap: cred.cap }); |
| const r = await fetch(`/api/connected/${encodeURIComponent(service)}`, { |
| method: "PUT", |
| headers: { "Content-Type": "application/json" }, |
| @@ -3266,6 +3281,13 @@ async function openOpChannel(service) { |
| async function requestOp(service, op, args, access, ttlMs = 120000, src = "") { |
| const ch = await openOpChannel(service); |
| if (ch.error) return { ok: false, error: ch.error }; |
| + // The connection's cap is the ceiling the member set when they connected: |
| + // a read-only connection never mints a write grant, whatever asked for it — |
| + // the refusal happens HERE, before any grant exists, so a tampered chip or |
| + // an over-eager proposal has nothing to escalate. |
| + if (!capAllows(ch.opened.cap, access)) { |
| + return { ok: false, error: `you connected ${service} read-only — change its access in Integrations to allow writes` }; |
| + } |
| const rid = bytesToB64url(crypto.getRandomValues(new Uint8Array(12))); |
| const grant = await sealForPub(ch.target.bot.public_key, { |
| service, |
| @@ -3293,7 +3315,9 @@ async function requestOp(service, op, args, access, ttlMs = 120000, src = "") { |
| // device (the one that triggered the rotation) is the one that persists it. |
| if (res.renewed) { |
| try { |
| - await storeCredential(service, await openSealed(res.renewed, session.userPriv)); |
| + // The rotated pair inherits the connection's cap — rotation refreshes |
| + // the token, never the member's access choice. |
| + await storeCredential(service, { ...(await openSealed(res.renewed, session.userPriv)), cap: ch.opened.cap }); |
| } catch {} |
| } |
| return { ok: true, ...res }; |
| @@ -3301,11 +3325,13 @@ async function requestOp(service, op, args, access, ttlMs = 120000, src = "") { |
| setHostBridge({ |
| connected: { |
| - async store(service, secret) { |
| + // cap is the member's access choice for this connection ("read" | |
| + // "write"); it's sealed into the credential blob alongside the token. |
| + async store(service, secret, cap) { |
| if (!session.userPriv) return { ok: false, error: "connecting services isn't supported in the native app yet" }; |
| const me = roster.find((e) => e.user_id === myUserId); |
| if (!me || !me.public_key) return { ok: false, error: "your encryption key isn't ready yet" }; |
| - const blob = await sealForPub(me.public_key, { secret }); |
| + const blob = await sealForPub(me.public_key, { secret, cap: cap || "read" }); |
| const r = await fetch(`/api/connected/${encodeURIComponent(service)}`, { |
| method: "PUT", |
| headers: { "Content-Type": "application/json" }, |
| @@ -3313,6 +3339,22 @@ setHostBridge({ |
| }); |
| return r.ok ? { ok: true } : { ok: false, error: `couldn't save (${r.status})` }; |
| }, |
| + // Re-seal the stored credential under a different access cap — the member |
| + // changing their mind in Integrations without re-pasting or re-consenting |
| + // at the provider. Requires their presence (userPriv) like every open. |
| + async setCap(service, cap) { |
| + if (!session.userPriv) return { ok: false, error: "connecting services isn't supported in the native app yet" }; |
| + try { |
| + 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` }; |
| + const opened = await openSealed(got.blob, session.userPriv); |
| + await storeCredential(service, { ...opened, cap: cap === "write" ? "write" : "read" }); |
| + return { ok: true }; |
| + } catch (e) { |
| + return { ok: false, error: (e && e.message) || "couldn't update the connection" }; |
| + } |
| + }, |
| // The connected service names, so the surface can show what's linked. |
| async list() { |
| const r = await fetch(`/api/connected?token=${encodeURIComponent(session.token)}`); |
| @@ -3350,10 +3392,10 @@ setHostBridge({ |
| // it), open synchronously so the click's user-gesture is kept and the popup |
| // navigates DIRECTLY to the URL. Uncached (a very fast click) falls back to a |
| // fetch-then-open, which the browser may block — rare, given the prefetch. |
| - startOAuth(service) { |
| + startOAuth(service, cap) { |
| const cached = oauthCfgCache[service]; |
| - if (cached) return Promise.resolve(openAuthorize(service, cached)); |
| - return loadOAuthCfg(service).then((cfg) => openAuthorize(service, cfg)); |
| + if (cached) return Promise.resolve(openAuthorize(service, cached, cap)); |
| + return loadOAuthCfg(service).then((cfg) => openAuthorize(service, cfg, cap)); |
| }, |
| request(service, op, arg, ttlMs = 120000) { |
| return (async () => { |
| @@ -3405,13 +3447,20 @@ setHostBridge({ |
| return null; |
| } |
| }, |
| - // Whether one service has a stored credential (the {connected} bit only — |
| - // never the blob). |
| + // Whether one service has a stored credential, plus its access cap when a |
| + // key is present to open the blob locally ("read" | "write"; undefined = |
| + // a legacy, cap-less connection). The blob itself never leaves this module. |
| async status(service) { |
| try { |
| const r = await fetch(`/api/connected/${encodeURIComponent(service)}?token=${encodeURIComponent(session.token)}`); |
| const got = r.ok ? await r.json() : {}; |
| - return { connected: !!got.connected }; |
| + const out = { connected: !!got.connected }; |
| + if (out.connected && got.blob && session.userPriv) { |
| + try { |
| + out.cap = (await openSealed(got.blob, session.userPriv)).cap; |
| + } catch {} |
| + } |
| + return out; |
| } catch { |
| return { connected: false }; |
| } |
diff --git a/web/static/ui.js b/web/static/ui.js| index c7a58b3..c761a8f 100644 |
| --- a/web/static/ui.js |
| +++ b/web/static/ui.js |
| @@ -319,6 +319,17 @@ export const CHIP_TYPES = { |
| }, |
| }; |
| +// capAllows: does a connection's access cap permit an op of this access level? |
| +// The cap is chosen by the member when they connect a service ("read" = read |
| +// only, "write" = read & write) and rides inside their sealed credential; the |
| +// client refuses to mint a grant beyond it. An absent cap is a legacy |
| +// connection (stored before caps existed) and keeps its old behaviour — both |
| +// levels allowed. Pure, so the rule is one testable place. |
| +export function capAllows(cap, access) { |
| + if (access !== "write") return true; // reads are always within any cap |
| + return cap !== "read"; // writes need a read+write (or legacy, cap-less) connection |
| +} |
| + |
| // sanitizeActions coerces an untrusted payload `actions` array to renderable |
| // chips: whitelist the type, validate the param (plus the type's own sanitize |
| // hook, when it has one), cap the count and label size. |