eleven / messenger Public

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>
Paul Campbell 845bd9135b1dba9efe8eced9257d0e064cd2f457 parent 876ebde
9 files changed, +234 −27
  • broker/main.go +15 −2
  • changelog/2026-07-11-connect-access-choice.md +7 −0
  • docs/integrations.md +14 −3
  • integrations/registry.go +5 −0
  • integrations/registry_test.go +15 −0
  • test/ui.test.mjs +15 −0
  • web/static/apps/connect.js +88 −7
  • web/static/shell.js +64 −15
  • web/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.