# 🤖 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.
