eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– App credentials: one key per space, asked once
2
3The problem: an app that needs an API key shouldn't ask every member for one.
4`/gif` is the worked example β€” it asks each person for their own free Giphy key
5and keeps it in that device's `localStorage` β€” but the case that matters is the
6one members *build*: "make me a weather app" is worthless if all twelve people
7in the space have to go register for an OpenWeather key.
8
9What we want: **the first person to use the app supplies the key; nobody in the
10space is ever asked again.**
11
12## The answer for most keys: put it in the fold
13
14A space-placed app's state already *is* one shared, end-to-end-encrypted value
15that every member of the space can read and nobody else can β€” including the
16server. That is not a compromise to work around; it is precisely what "the key
17belongs to the space, not to a device" means. So:
18
19> **A shared app key lives in the space app's fold state, like any other space
20> app data. No new store, no new table, no new sealing decision.**
21
22`places: ["space"]` and the existing space thread (`docs/space-apps.md`) already
23do the whole job. The first member to run the command is asked; the key folds
24into the space log; every other member β€” and every later joiner, via the
25instance key β€” reads it and is never asked.
26
27### Why not the connected-services store?
28
29Because it solves a different problem. `connected.go` holds one blob per
30`(member, service)`, sealed to **that member's own public key**, and exists for
31exactly one reason: to keep a credential secret *from other members* β€” "my
32GitHub token is mine." Remove that requirement and the store's reason for
33existing goes with it. A space key is *supposed* to be readable by the space.
34Reaching for the per-member store here would be solving a problem we don't have.
35
36### Why not the Vault?
37
38The Vault is space-capable too, and "one credential shelf for the space" sounds
39like it should host this. It shouldn't, and the line is a **category** one, not a
40UX one: the Vault holds **secrets a person chose to keep** β€” things a member
41decides are worth storing and would recognise on a shelf. An app key is **config
42the app needs to function**: nobody wants it, the app just can't run without it.
43Filing app config on the members' secret shelf would put an implementation detail
44in a surface people curate, and make one app's data another app's dependency.
45
46Two apps landing on the same trust posture β€” shared fold state, every member
47reads it, additions attributed β€” is convergence on *how* to store a shared value,
48not evidence they should share a *store*.
49
50### The one case the fold doesn't cover
51
52Fold state can't keep a secret from a member of the thread β€” the guide says so
53outright (`docs/building-member-apps.md:389`: a group-capable app has "no way to
54keep a secret from one member of the group"). Every member decrypts every event
55and snapshot by construction.
56
57For a public client identifier (Giphy) that is a non-issue: exposure is
58harmless, and sharing is the whole point. It only bites when a value must be
59usable by the space but *not readable* by its members β€” a metered key with a
60real bill, or a token with write access to something outside the space. That is
61a genuinely different feature, and if it is ever wanted the shape is known:
62seal it to the **bot's** public key, so no member reads it and it is usable only
63through the grant path below. Out of scope here; noted so the boundary is
64explicit.
65
66One small consequence worth knowing either way: a log is immutable, so rotating
67a key leaves the old value readable in the log's history forever. For a public
68client id, who cares. For anything metered, that is a reason to think again.
69
70## Using the key
71
72This is the half with real difficulty, and it is unchanged by the above.
73
74A member app **cannot fetch**. Not "shouldn't" β€” cannot, walled twice:
75
76- its own document is `default-src 'none'; … connect-src 'none'`
77 (`memberapps.go:263`), with a **hash-pinned** `script-src` (a nonce would be
78 readable by same-document JS and could bypass the host allowlist);
79- the frame's `ctx` is built from scratch and has **no `host`**
80 (`sandbox-frame.js:468`); the init message carries only
81 `{source, manifest, threadId, me, theme, seed}` β€” no keys, no tokens
82 (`sandbox.js:321`).
83
84So an external-API member app is **forced** through the bot sidecar. That is a
85consequence of the cage, not a design choice β€” and it has a consequence the
86product must own:
87
88> **External-API member apps require a running, model-enabled bot sidecar. A
89> space without one cannot run them.**
90
91### Getting the key to the sidecar: two paths, both already built
92
93It would be easy to read the sandbox walls as "so the backend can't have the
94key". That is wrong, and the distinction matters because the two paths cover
95different cases.
96
97**Path A β€” the per-op grant (a member is present).** `shell.js:4492` seals
98`{service, token, exp}` to the **bot's** public key for one operation; the
99sidecar opens it with its own private key, acts once, and drops the token
100(`internal/bot/connect.go`). The external HTTP call happens **in the sidecar,
101never the browser** β€” which is why the page CSP never needed `api.github.com`.
102
103> **A credential can be used by code that never sees it.**
104
105This composes with fold state directly: the browser reads the key from the space
106fold and seals it per-op. Nothing new. But it only works **while a member's tab
107is open** β€” which is fine for "someone typed a command" and useless for "every
108morning at 6am".
109
110**Path B β€” the sidecar reads the fold itself (nobody is present).** The bot
111already holds the **instance key**: it fetches its own wrapped copy and unwraps
112it with its own private key (`internal/bot/appbuild.go:765-777`). That is not a
113detail β€” it is how charters work at all, since a charter is sealed under the
114instance key (`chartersched.go` calls `r.instanceKey()` before it can list
115anything).
116
117The space thread's key rides `open_key`, wrapped under that same instance key. So
118a sidecar is **cryptographically capable** of reading the space fold. It is
119excluded by *policy*, not by a wall: `spacethread.go:60` gates on
120`isAgent(uid) && !threadMember(id, uid)` β€” an agent with an explicit membership
121row **passes today**, and gets `open_key` from the existing endpoint with no new
122server code. (Agents still can't *mint* the thread β€” `spacethread.go:115` β€” since
123the creator necessarily knows the key it wraps.)
124
125That is the path `docs/space-apps.md` already anticipated:
126
127> Bots in the space thread: excluded by default; a deliberate opt-in flow
128> (disclosed, like adding a bot to a group) would let a bot serve space-wide apps.
129
130So autonomous, scheduled backend work needs **no browser and no new crypto** β€” it
131needs a membership row and the disclosure flow around it. What's missing is the
132*flow*, not the capability.
133
134| | member present | nobody present |
135|---|---|---|
136| **Path A** β€” per-op grant | βœ… works today | ❌ no tab to seal it |
137| **Path B** β€” bot in the space thread | βœ… | βœ… needs the opt-in flow (server side already allows it) |
138
139Path B is the stronger claim and deserves the scrutiny: adding a bot to the space
140thread means that machine can decrypt **every space app's data**, not just the one
141credential it needed. That is the same bargain as adding a bot to a group β€” which
142is why it must be disclosed and deliberate, and why "the app needs a key at 6am"
143should not silently buy it.
144
145The remaining plumbing is one sanitized bridge RPC (a sibling of `schedule` /
146`panel-open` in `sandbox.js`'s `onFrameMessage`) forwarding `{service, op, args}`
147from the frame to the host; the host mints the grant and routes to the sidecar;
148the frame gets back cleaned JSON. The token never enters the frame. Both walls
149stand.
150
151### The crux: an arbitrary app has no reviewed ops
152
153`requestOp` routes to a **closed, reviewed** op list per service
154(`integrations/registry.go`: a manifest is "the WHOLE of what an integration can
155do β€” the one API host its ops may touch and the closed list of operations"). A
156member-built app that wants some API nobody has integrated has no manifest to
157name β€” and the obvious escape hatch, a generic HTTP-proxy op, hands the app
158control of the URL and body, which is precisely the exfiltration channel
159`exec.go`'s invariant exists to deny:
160
161> The manifest is reviewed code; the args and token are member input… **A value
162> can steer WHAT an op says, never WHERE it goes.** (`integrations/exec.go:18`)
163
164Grant that and the cage's guarantee β€” "the worst a hostile app can do is corrupt
165its own pane" β€” is gone.
166
167**The way out, proposed:** the registry is *declarative JSON*, and its validator
168is already exported and fails closed β€” `integrations.Parse(raw []byte)` +
169`validate()`. The security property lives in `validate()`, **not** in being
170embedded in the binary. So the registry can become **admin-extensible per space**
171without weakening anything: an admin adds a service manifest (pinned host, named
172ops, declared args, declared response projection), the same validator accepts or
173rejects it, and `exec.go` re-checks the final URL against `auth.base`'s host at
174*request* time β€” so even a manifest that tried to smuggle a second host is caught
175at execution, not merely at validation.
176
177That moves the review step from "a PR to this repo" to "an admin of this space",
178which is the same trust rung that already vets custom apps and sets the
179space-install policy. It does **not** let a member point an app at an arbitrary
180host β€” that remains impossible by construction, which is the point.
181
182## `/gif` is the odd one out β€” and is SHIPPED
183
184Worth saying plainly, because it motivated this and then turns out to be
185atypical: gif's fetch is **client-side** (browser β†’ `*.giphy.com`, CSP-pinned at
186`main.go:710`), not sidecar-side. It needs no grant, no sidecar, and no op
187manifest. It is a compiled-in trusted app reading a public client id.
188
189So gif was fixed by the fold alone, and that half is **built**: `places:
190["space"]` + `listed: false`, the key folding from a `gif.key` event in the space
191log, and copy that says the key is shared with everyone here. It is not the
192template for member apps β€” it is the one case that doesn't need the hard half at
193all.
194
195Two things the implementation taught, both worth knowing before the member-app
196half is attempted:
197
198- **A space-placed command app straddles two threads.** `ctx` addresses the space
199 (its data); the panel and any posted content address the chat. `ctx.panel`
200 already resolved this β€” it binds to `activeThreadId`, not `ctx.threadId`
201 (`apps.js` runCommand) β€” but *posting* did not: gif used to
202 `gifHost.upload(ctx.threadId)` + `ctx.emit`, which under space routing would
203 have uploaded the GIF under the space key and posted it into a thread that
204 renders nowhere. Posting moved into the host doorway (`gif.post`), which reads
205 `activeId` itself. An app gets no say in the destination β€” the host owns "here".
206 This is exactly `docs/space-apps.md`'s "app data lives space-wide, conversation
207 content stays under the conversation's own key", and any future space-placed
208 command app inherits the same split.
209- **Unroutable commands used to fail silently.** `runCommand` returned without a
210 word when `instanceThread` gave null, so `/gif` with no space thread (a native
211 embed, or an instance key still syncing) would have done nothing at all. It now
212 answers "`/gif` isn't available here yet" β€” true for every app, not just this
213 one.
214
215## Implicitly space-placed (the "unlisted" question) β€” RESOLVED
216
217Space is the right *place* for an app like gif β€” its data (the key) belongs to
218the space, and its commands already work in every chat. But a plain `places:
219["space"]` would drag it into the Apps catalogue, which is wrong for a
220command-only utility that "acts where you type, lives nowhere".
221
222**No new flag was needed.** `listed: false` already meant "not user-instantiable"
223(`apps.js`), and `normalizePlaces` only *defaults* `listed` from `places.length`
224β€” an explicit flag wins. So `places: ["space"] + listed: false` says it exactly.
225
226The rule that makes it work, in `instanceThread`: **an unlisted space-capable app
227is implicitly space-placed** β€” it has no Add button, so there is no install event
228to gate on, and its one shared copy simply *is* the space's. That mirrors the
229space thread's own implicit membership, and it sidesteps the admins-only install
230policy entirely: there is no install to be inert. The thread is minted on first
231use by the resolver.
232
233The rule is load-bearing, not cosmetic. Without it a `spaceCapable` app that
234isn't *installed* falls through to `return activeThreadId`, and gif's key would
235fold into whatever conversation the member happened to be typing in β€” readable by
236that chat's members, in the wrong log, forever.
237
238The other two surfaces needed nothing: both the widget bar and the sidebar band
239filter on an app-level `render`, and gif has none (its `render` is the *panel's*).
240
241## Scope
242
243- **Web first, native follows** β€” consistent with how space apps shipped, and now
244 the one loose end of the shipped half. `loadSpaceThread` bails on `embed` by
245 design, so a native `/gif` finds no space thread and answers "isn't available
246 here yet" rather than working. That is a **regression in reach** against the old
247 device-local key, taken deliberately: a per-device fallback would resurrect
248 exactly what this replaced, and silence would have been worse than either. Both
249 natives already seed `instanceKey` into the embed
250 (`ios/LChat/FeedWebCache.swift:172`, `ios/LChatMac/CommandPanelWebView.swift:51`),
251 so the fix is short β€” let `bootEmbed` load the space thread when it has the key.
252 **iOS first** (CLAUDE.md), and note only the Mac has a command host today.
253- **Member apps depend on the sidecar** and carry the registry question above.
254
255## Open questions
256
2571. **Native `/gif`** (above) β€” the immediate follow-up, and the only thing the
258 shipped half left undone.
2592. **Admin-extensible registry** β€” is moving the review rung from repo to admin
260 acceptable? It is the crux of the use-path and the one genuinely new trust
261 claim here.
2623. **The bot-in-the-space-thread opt-in (Path B).** The server already permits it
263 (`spacethread.go:60`); what's missing is the disclosure flow and its blast
264 radius, since a bot in the space thread reads *every* space app's data, not
265 just the credential that motivated it. This β€” not key delivery β€” is what
266 autonomous scheduled apps actually wait on.
2674. **Rate-limit attribution.** A shared key means one member's quota (or bill)
268 serves the space. Disclosure covers Giphy; a metered API deserves more thought
269 β€” and is the case that might yet want the bot-sealed variant above.
2705. **Event vectors deliberately skipped.** `gif.key` is a new app event, and
271 CLAUDE.md's contract says new app features add a vector. gif has no native
272 side (and, per the scope note, won't until `bootEmbed` learns the space
273 thread), so there is no second implementation for a vector to hold in step
274 yet. It gets one when native `/gif` lands β€” recorded here so the omission is a
275 decision, not a miss.
276