| 1 | # π€ App credentials: one key per space, asked once |
| 2 | |
| 3 | The 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 |
| 5 | and keeps it in that device's `localStorage` β but the case that matters is the |
| 6 | one members *build*: "make me a weather app" is worthless if all twelve people |
| 7 | in the space have to go register for an OpenWeather key. |
| 8 | |
| 9 | What we want: **the first person to use the app supplies the key; nobody in the |
| 10 | space is ever asked again.** |
| 11 | |
| 12 | ## The answer for most keys: put it in the fold |
| 13 | |
| 14 | A space-placed app's state already *is* one shared, end-to-end-encrypted value |
| 15 | that every member of the space can read and nobody else can β including the |
| 16 | server. That is not a compromise to work around; it is precisely what "the key |
| 17 | belongs 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 |
| 23 | do the whole job. The first member to run the command is asked; the key folds |
| 24 | into the space log; every other member β and every later joiner, via the |
| 25 | instance key β reads it and is never asked. |
| 26 | |
| 27 | ### Why not the connected-services store? |
| 28 | |
| 29 | Because 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 |
| 31 | exactly one reason: to keep a credential secret *from other members* β "my |
| 32 | GitHub token is mine." Remove that requirement and the store's reason for |
| 33 | existing goes with it. A space key is *supposed* to be readable by the space. |
| 34 | Reaching for the per-member store here would be solving a problem we don't have. |
| 35 | |
| 36 | ### Why not the Vault? |
| 37 | |
| 38 | The Vault is space-capable too, and "one credential shelf for the space" sounds |
| 39 | like it should host this. It shouldn't, and the line is a **category** one, not a |
| 40 | UX one: the Vault holds **secrets a person chose to keep** β things a member |
| 41 | decides are worth storing and would recognise on a shelf. An app key is **config |
| 42 | the app needs to function**: nobody wants it, the app just can't run without it. |
| 43 | Filing app config on the members' secret shelf would put an implementation detail |
| 44 | in a surface people curate, and make one app's data another app's dependency. |
| 45 | |
| 46 | Two apps landing on the same trust posture β shared fold state, every member |
| 47 | reads it, additions attributed β is convergence on *how* to store a shared value, |
| 48 | not evidence they should share a *store*. |
| 49 | |
| 50 | ### The one case the fold doesn't cover |
| 51 | |
| 52 | Fold state can't keep a secret from a member of the thread β the guide says so |
| 53 | outright (`docs/building-member-apps.md:389`: a group-capable app has "no way to |
| 54 | keep a secret from one member of the group"). Every member decrypts every event |
| 55 | and snapshot by construction. |
| 56 | |
| 57 | For a public client identifier (Giphy) that is a non-issue: exposure is |
| 58 | harmless, and sharing is the whole point. It only bites when a value must be |
| 59 | usable by the space but *not readable* by its members β a metered key with a |
| 60 | real bill, or a token with write access to something outside the space. That is |
| 61 | a genuinely different feature, and if it is ever wanted the shape is known: |
| 62 | seal it to the **bot's** public key, so no member reads it and it is usable only |
| 63 | through the grant path below. Out of scope here; noted so the boundary is |
| 64 | explicit. |
| 65 | |
| 66 | One small consequence worth knowing either way: a log is immutable, so rotating |
| 67 | a key leaves the old value readable in the log's history forever. For a public |
| 68 | client id, who cares. For anything metered, that is a reason to think again. |
| 69 | |
| 70 | ## Using the key |
| 71 | |
| 72 | This is the half with real difficulty, and it is unchanged by the above. |
| 73 | |
| 74 | A 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 | |
| 84 | So an external-API member app is **forced** through the bot sidecar. That is a |
| 85 | consequence of the cage, not a design choice β and it has a consequence the |
| 86 | product 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 | |
| 93 | It would be easy to read the sandbox walls as "so the backend can't have the |
| 94 | key". That is wrong, and the distinction matters because the two paths cover |
| 95 | different 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 |
| 99 | sidecar 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, |
| 101 | never 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 | |
| 105 | This composes with fold state directly: the browser reads the key from the space |
| 106 | fold and seals it per-op. Nothing new. But it only works **while a member's tab |
| 107 | is open** β which is fine for "someone typed a command" and useless for "every |
| 108 | morning at 6am". |
| 109 | |
| 110 | **Path B β the sidecar reads the fold itself (nobody is present).** The bot |
| 111 | already holds the **instance key**: it fetches its own wrapped copy and unwraps |
| 112 | it with its own private key (`internal/bot/appbuild.go:765-777`). That is not a |
| 113 | detail β it is how charters work at all, since a charter is sealed under the |
| 114 | instance key (`chartersched.go` calls `r.instanceKey()` before it can list |
| 115 | anything). |
| 116 | |
| 117 | The space thread's key rides `open_key`, wrapped under that same instance key. So |
| 118 | a sidecar is **cryptographically capable** of reading the space fold. It is |
| 119 | excluded by *policy*, not by a wall: `spacethread.go:60` gates on |
| 120 | `isAgent(uid) && !threadMember(id, uid)` β an agent with an explicit membership |
| 121 | row **passes today**, and gets `open_key` from the existing endpoint with no new |
| 122 | server code. (Agents still can't *mint* the thread β `spacethread.go:115` β since |
| 123 | the creator necessarily knows the key it wraps.) |
| 124 | |
| 125 | That 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 | |
| 130 | So autonomous, scheduled backend work needs **no browser and no new crypto** β it |
| 131 | needs 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 | |
| 139 | Path B is the stronger claim and deserves the scrutiny: adding a bot to the space |
| 140 | thread means that machine can decrypt **every space app's data**, not just the one |
| 141 | credential it needed. That is the same bargain as adding a bot to a group β which |
| 142 | is why it must be disclosed and deliberate, and why "the app needs a key at 6am" |
| 143 | should not silently buy it. |
| 144 | |
| 145 | The remaining plumbing is one sanitized bridge RPC (a sibling of `schedule` / |
| 146 | `panel-open` in `sandbox.js`'s `onFrameMessage`) forwarding `{service, op, args}` |
| 147 | from the frame to the host; the host mints the grant and routes to the sidecar; |
| 148 | the frame gets back cleaned JSON. The token never enters the frame. Both walls |
| 149 | stand. |
| 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 |
| 155 | do β the one API host its ops may touch and the closed list of operations"). A |
| 156 | member-built app that wants some API nobody has integrated has no manifest to |
| 157 | name β and the obvious escape hatch, a generic HTTP-proxy op, hands the app |
| 158 | control 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 | |
| 164 | Grant that and the cage's guarantee β "the worst a hostile app can do is corrupt |
| 165 | its own pane" β is gone. |
| 166 | |
| 167 | **The way out, proposed:** the registry is *declarative JSON*, and its validator |
| 168 | is already exported and fails closed β `integrations.Parse(raw []byte)` + |
| 169 | `validate()`. The security property lives in `validate()`, **not** in being |
| 170 | embedded in the binary. So the registry can become **admin-extensible per space** |
| 171 | without weakening anything: an admin adds a service manifest (pinned host, named |
| 172 | ops, declared args, declared response projection), the same validator accepts or |
| 173 | rejects 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 |
| 175 | at execution, not merely at validation. |
| 176 | |
| 177 | That moves the review step from "a PR to this repo" to "an admin of this space", |
| 178 | which is the same trust rung that already vets custom apps and sets the |
| 179 | space-install policy. It does **not** let a member point an app at an arbitrary |
| 180 | host β that remains impossible by construction, which is the point. |
| 181 | |
| 182 | ## `/gif` is the odd one out β and is SHIPPED |
| 183 | |
| 184 | Worth saying plainly, because it motivated this and then turns out to be |
| 185 | atypical: 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 |
| 187 | manifest. It is a compiled-in trusted app reading a public client id. |
| 188 | |
| 189 | So 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 |
| 191 | log, and copy that says the key is shared with everyone here. It is not the |
| 192 | template for member apps β it is the one case that doesn't need the hard half at |
| 193 | all. |
| 194 | |
| 195 | Two things the implementation taught, both worth knowing before the member-app |
| 196 | half 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 | |
| 217 | Space is the right *place* for an app like gif β its data (the key) belongs to |
| 218 | the 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 |
| 220 | command-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 | |
| 226 | The rule that makes it work, in `instanceThread`: **an unlisted space-capable app |
| 227 | is implicitly space-placed** β it has no Add button, so there is no install event |
| 228 | to gate on, and its one shared copy simply *is* the space's. That mirrors the |
| 229 | space thread's own implicit membership, and it sidesteps the admins-only install |
| 230 | policy entirely: there is no install to be inert. The thread is minted on first |
| 231 | use by the resolver. |
| 232 | |
| 233 | The rule is load-bearing, not cosmetic. Without it a `spaceCapable` app that |
| 234 | isn't *installed* falls through to `return activeThreadId`, and gif's key would |
| 235 | fold into whatever conversation the member happened to be typing in β readable by |
| 236 | that chat's members, in the wrong log, forever. |
| 237 | |
| 238 | The other two surfaces needed nothing: both the widget bar and the sidebar band |
| 239 | filter 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 | |
| 257 | 1. **Native `/gif`** (above) β the immediate follow-up, and the only thing the |
| 258 | shipped half left undone. |
| 259 | 2. **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. |
| 262 | 3. **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. |
| 267 | 4. **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. |
| 270 | 5. **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 | |