eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

πŸ€– App credentials: one key per space, asked once

The problem: an app that needs an API key shouldn't ask every member for one. /gif is the worked example β€” it asks each person for their own free Giphy key and keeps it in that device's localStorage β€” but the case that matters is the one members build: "make me a weather app" is worthless if all twelve people in the space have to go register for an OpenWeather key.

What we want: the first person to use the app supplies the key; nobody in the space is ever asked again.

The answer for most keys: put it in the fold

A space-placed app's state already is one shared, end-to-end-encrypted value that every member of the space can read and nobody else can β€” including the server. That is not a compromise to work around; it is precisely what "the key belongs to the space, not to a device" means. So:

> A shared app key lives in the space app's fold state, like any other space > app data. No new store, no new table, no new sealing decision.

places: ["space"] and the existing space thread (docs/space-apps.md) already do the whole job. The first member to run the command is asked; the key folds into the space log; every other member β€” and every later joiner, via the instance key β€” reads it and is never asked.

Why not the connected-services store?

Because it solves a different problem. connected.go holds one blob per (member, service), sealed to that member's own public key, and exists for exactly one reason: to keep a credential secret from other members β€” "my GitHub token is mine." Remove that requirement and the store's reason for existing goes with it. A space key is supposed to be readable by the space. Reaching for the per-member store here would be solving a problem we don't have.

Why not the Vault?

The Vault is space-capable too, and "one credential shelf for the space" sounds like it should host this. It shouldn't, and the line is a category one, not a UX one: the Vault holds secrets a person chose to keep β€” things a member decides are worth storing and would recognise on a shelf. An app key is config the app needs to function: nobody wants it, the app just can't run without it. Filing app config on the members' secret shelf would put an implementation detail in a surface people curate, and make one app's data another app's dependency.

Two apps landing on the same trust posture β€” shared fold state, every member reads it, additions attributed β€” is convergence on how to store a shared value, not evidence they should share a store.

The one case the fold doesn't cover

Fold state can't keep a secret from a member of the thread β€” the guide says so outright (docs/building-member-apps.md:389: a group-capable app has "no way to keep a secret from one member of the group"). Every member decrypts every event and snapshot by construction.

For a public client identifier (Giphy) that is a non-issue: exposure is harmless, and sharing is the whole point. It only bites when a value must be usable by the space but not readable by its members β€” a metered key with a real bill, or a token with write access to something outside the space. That is a genuinely different feature, and if it is ever wanted the shape is known: seal it to the bot's public key, so no member reads it and it is usable only through the grant path below. Out of scope here; noted so the boundary is explicit.

One small consequence worth knowing either way: a log is immutable, so rotating a key leaves the old value readable in the log's history forever. For a public client id, who cares. For anything metered, that is a reason to think again.

Using the key

This is the half with real difficulty, and it is unchanged by the above.

A member app cannot fetch. Not "shouldn't" β€” cannot, walled twice:

  • its own document is default-src 'none'; … connect-src 'none' (memberapps.go:263), with a hash-pinned script-src (a nonce would be readable by same-document JS and could bypass the host allowlist);
  • the frame's ctx is built from scratch and has no host (sandbox-frame.js:468); the init message carries only {source, manifest, threadId, me, theme, seed} β€” no keys, no tokens (sandbox.js:321).

So an external-API member app is forced through the bot sidecar. That is a consequence of the cage, not a design choice β€” and it has a consequence the product must own:

> External-API member apps require a running, model-enabled bot sidecar. A > space without one cannot run them.

Getting the key to the sidecar: two paths, both already built

It would be easy to read the sandbox walls as "so the backend can't have the key". That is wrong, and the distinction matters because the two paths cover different cases.

Path A β€” the per-op grant (a member is present). shell.js:4492 seals {service, token, exp} to the bot's public key for one operation; the sidecar opens it with its own private key, acts once, and drops the token (internal/bot/connect.go). The external HTTP call happens in the sidecar, never the browser β€” which is why the page CSP never needed api.github.com.

> A credential can be used by code that never sees it.

This composes with fold state directly: the browser reads the key from the space fold and seals it per-op. Nothing new. But it only works while a member's tab is open β€” which is fine for "someone typed a command" and useless for "every morning at 6am".

Path B β€” the sidecar reads the fold itself (nobody is present). The bot already holds the instance key: it fetches its own wrapped copy and unwraps it with its own private key (internal/bot/appbuild.go:765-777). That is not a detail β€” it is how charters work at all, since a charter is sealed under the instance key (chartersched.go calls r.instanceKey() before it can list anything).

The space thread's key rides open_key, wrapped under that same instance key. So a sidecar is cryptographically capable of reading the space fold. It is excluded by policy, not by a wall: spacethread.go:60 gates on isAgent(uid) && !threadMember(id, uid) β€” an agent with an explicit membership row passes today, and gets open_key from the existing endpoint with no new server code. (Agents still can't mint the thread β€” spacethread.go:115 β€” since the creator necessarily knows the key it wraps.)

That is the path docs/space-apps.md already anticipated:

> Bots in the space thread: excluded by default; a deliberate opt-in flow > (disclosed, like adding a bot to a group) would let a bot serve space-wide apps.

So autonomous, scheduled backend work needs no browser and no new crypto β€” it needs a membership row and the disclosure flow around it. What's missing is the flow, not the capability.

| | member present | nobody present | |---|---|---| | Path A β€” per-op grant | βœ… works today | ❌ no tab to seal it | | Path B β€” bot in the space thread | βœ… | βœ… needs the opt-in flow (server side already allows it) |

Path B is the stronger claim and deserves the scrutiny: adding a bot to the space thread means that machine can decrypt every space app's data, not just the one credential it needed. That is the same bargain as adding a bot to a group β€” which is why it must be disclosed and deliberate, and why "the app needs a key at 6am" should not silently buy it.

The remaining plumbing is one sanitized bridge RPC (a sibling of schedule / panel-open in sandbox.js's onFrameMessage) forwarding {service, op, args} from the frame to the host; the host mints the grant and routes to the sidecar; the frame gets back cleaned JSON. The token never enters the frame. Both walls stand.

The crux: an arbitrary app has no reviewed ops

requestOp routes to a closed, reviewed op list per service (integrations/registry.go: a manifest is "the WHOLE of what an integration can do β€” the one API host its ops may touch and the closed list of operations"). A member-built app that wants some API nobody has integrated has no manifest to name β€” and the obvious escape hatch, a generic HTTP-proxy op, hands the app control of the URL and body, which is precisely the exfiltration channel exec.go's invariant exists to deny:

> The manifest is reviewed code; the args and token are member input… A value > can steer WHAT an op says, never WHERE it goes. (integrations/exec.go:18)

Grant that and the cage's guarantee β€” "the worst a hostile app can do is corrupt its own pane" β€” is gone.

The way out, proposed: the registry is declarative JSON, and its validator is already exported and fails closed β€” integrations.Parse(raw []byte) + validate(). The security property lives in validate(), not in being embedded in the binary. So the registry can become admin-extensible per space without weakening anything: an admin adds a service manifest (pinned host, named ops, declared args, declared response projection), the same validator accepts or rejects it, and exec.go re-checks the final URL against auth.base's host at request time β€” so even a manifest that tried to smuggle a second host is caught at execution, not merely at validation.

That moves the review step from "a PR to this repo" to "an admin of this space", which is the same trust rung that already vets custom apps and sets the space-install policy. It does not let a member point an app at an arbitrary host β€” that remains impossible by construction, which is the point.

/gif is the odd one out β€” and is SHIPPED

Worth saying plainly, because it motivated this and then turns out to be atypical: gif's fetch is client-side (browser β†’ *.giphy.com, CSP-pinned at main.go:710), not sidecar-side. It needs no grant, no sidecar, and no op manifest. It is a compiled-in trusted app reading a public client id.

So gif was fixed by the fold alone, and that half is built: places: ["space"] + listed: false, the key folding from a gif.key event in the space log, and copy that says the key is shared with everyone here. It is not the template for member apps β€” it is the one case that doesn't need the hard half at all.

Two things the implementation taught, both worth knowing before the member-app half is attempted:

  • A space-placed command app straddles two threads. ctx addresses the space (its data); the panel and any posted content address the chat. ctx.panel already resolved this β€” it binds to activeThreadId, not ctx.threadId (apps.js runCommand) β€” but posting did not: gif used to gifHost.upload(ctx.threadId) + ctx.emit, which under space routing would have uploaded the GIF under the space key and posted it into a thread that renders nowhere. Posting moved into the host doorway (gif.post), which reads activeId itself. An app gets no say in the destination β€” the host owns "here". This is exactly docs/space-apps.md's "app data lives space-wide, conversation content stays under the conversation's own key", and any future space-placed command app inherits the same split.
  • Unroutable commands used to fail silently. runCommand returned without a word when instanceThread gave null, so /gif with no space thread (a native embed, or an instance key still syncing) would have done nothing at all. It now answers "/gif isn't available here yet" β€” true for every app, not just this one.

Implicitly space-placed (the "unlisted" question) β€” RESOLVED

Space is the right place for an app like gif β€” its data (the key) belongs to the space, and its commands already work in every chat. But a plain places: ["space"] would drag it into the Apps catalogue, which is wrong for a command-only utility that "acts where you type, lives nowhere".

No new flag was needed. listed: false already meant "not user-instantiable" (apps.js), and normalizePlaces only defaults listed from places.length β€” an explicit flag wins. So places: ["space"] + listed: false says it exactly.

The rule that makes it work, in instanceThread: an unlisted space-capable app is implicitly space-placed β€” it has no Add button, so there is no install event to gate on, and its one shared copy simply is the space's. That mirrors the space thread's own implicit membership, and it sidesteps the admins-only install policy entirely: there is no install to be inert. The thread is minted on first use by the resolver.

The rule is load-bearing, not cosmetic. Without it a spaceCapable app that isn't installed falls through to return activeThreadId, and gif's key would fold into whatever conversation the member happened to be typing in β€” readable by that chat's members, in the wrong log, forever.

The other two surfaces needed nothing: both the widget bar and the sidebar band filter on an app-level render, and gif has none (its render is the panel's).

Scope

  • Web first, native follows β€” consistent with how space apps shipped, and now the one loose end of the shipped half. loadSpaceThread bails on embed by design, so a native /gif finds no space thread and answers "isn't available here yet" rather than working. That is a regression in reach against the old device-local key, taken deliberately: a per-device fallback would resurrect exactly what this replaced, and silence would have been worse than either. Both natives already seed instanceKey into the embed (ios/LChat/FeedWebCache.swift:172, ios/LChatMac/CommandPanelWebView.swift:51), so the fix is short β€” let bootEmbed load the space thread when it has the key. iOS first (CLAUDE.md), and note only the Mac has a command host today.
  • Member apps depend on the sidecar and carry the registry question above.

Open questions

  1. Native /gif (above) β€” the immediate follow-up, and the only thing the shipped half left undone.
  2. Admin-extensible registry β€” is moving the review rung from repo to admin acceptable? It is the crux of the use-path and the one genuinely new trust claim here.
  3. The bot-in-the-space-thread opt-in (Path B). The server already permits it (spacethread.go:60); what's missing is the disclosure flow and its blast radius, since a bot in the space thread reads every space app's data, not just the credential that motivated it. This β€” not key delivery β€” is what autonomous scheduled apps actually wait on.
  4. Rate-limit attribution. A shared key means one member's quota (or bill) serves the space. Disclosure covers Giphy; a metered API deserves more thought β€” and is the case that might yet want the bot-sealed variant above.
  5. Event vectors deliberately skipped. gif.key is a new app event, and CLAUDE.md's contract says new app features add a vector. gif has no native side (and, per the scope note, won't until bootEmbed learns the space thread), so there is no second implementation for a vector to hold in step yet. It gets one when native /gif lands β€” recorded here so the omission is a decision, not a miss.