eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Member apps: LLM-built or member-written, member-deployed, sandboxed
2
3Members get an app two ways: ask a bot in plain chat ("build me a habit
4tracker"), or write the code themselves and deploy it from **App Studio**.
5Either way it lands in the Apps menu a moment later. This document is the
6design: what runs where, what the server can see, and why untrusted code can't
7touch anything that matters. The authoring rules live in
8`docs/building-member-apps.md` (served at `/api/member-app-guide`); the
9mini-app platform itself in `web/static/apps/README.md`.
10
11## Two trust tiers
12
13| | custom apps (existing) | member apps (this doc) |
14|---|---|---|
15| who ships it | the instance **admin** | any **member** (usually via a bot) |
16| what it is | CODE the admin vetted | USER CONTENT |
17| stored as | plaintext JS (`custom_apps`) | instance-key-encrypted blob (`member_apps`) |
18| served as | an executable ES module | never β€” the server can't even read it |
19| runs | in the page, full trust | in an opaque-origin sandboxed iframe |
20| install gate | admin token | member token; author-only overwrite |
21| moderation | admin delete | author or admin delete |
22
23The existing tier is unchanged. The new tier exists because "anyone can add
24code" and "code runs with access to everything a device can decrypt" cannot
25coexist β€” so member apps get a cage instead of a vetting step.
26
27## The loop, end to end
28
291. **Ask.** A member tells a model-enabled bot what they want. The bot's
30 system prompt teaches it to interview briefly, then call its `create_app`
31 tool with a name/title/glyph/spec (`internal/bot/brain.go`).
322. **Build.** The sidecar runs a dedicated generation call against the
33 instance's own authoring guide (fetched from `/api/member-app-guide`, so
34 bot and server can never disagree), then statically validates the module β€”
35 allowed imports only, no network/storage APIs, one `defineApp`, namespaced
36 event types β€” and regenerates on failure (`internal/bot/appbuild.go`).
37 This validation is a *quality* gate; the sandbox below is the security
38 boundary.
393. **Deploy.** The bot β€” an ordinary E2EE member holding its own wrapped copy
40 of the instance key β€” encrypts `{ source, manifest }` under that key and
41 `PUT`s the blob to `/api/member-apps/{id}` with its member token
42 (`memberapps.go`). The id is random; the server learns nothing but
43 (id, author, size, time). `apps_changed` broadcasts and clients reload.
444. **Launch.** At boot the client fetches the list, decrypts each blob with
45 the instance key, validates the manifest, and registers a **proxy app**
46 through the ordinary `defineApp` (`web/static/sandbox.js`) β€” so the app
47 appears in New chat's Apps list, gets its own personal thread, folds
48 events, and snapshots exactly like a built-in. Opening it mounts the
49 sandbox frame; the member sees their custom UI full-pane in the shell.
50
51## Fixing an existing app: the bot's eyes
52
53Building is only half the loop β€” members come back with "the done button does
54nothing, fix it". The server can't help (it holds ciphertext), so the bot's
55sight is client-side, built from the same instance key it deploys with
56(`internal/bot/appbuild.go`):
57
58- **Discovery.** Every model reply carries a per-sender system note listing
59 the apps *that member owns* β€” exact `app_id`, title, name, version, builder
60 β€” decrypted from the deployed list (briefly cached; the sidecar's own
61 deploys invalidate it). Without this, the model only ever knew an id that
62 happened to appear in the recent conversation, and "fix my habit tracker"
63 from a fresh chat silently became a blind second build.
64- **Reading.** A `read_app_source` tool returns one app's manifest + current
65 source (owner-gated, exactly like updating), run in a short bounded tool
66 loop inside the reply β€” so the bot can diagnose a bug or explain the app's
67 behaviour before (or without) rebuilding.
68- **Updating.** `create_app` with the `app_id` from the note; the build call
69 regenerates from the app's current source, so the fix is an edit, not a
70 from-scratch rewrite. The deploy verifies and announces the new version,
71 and the old one stays in App Studio's History to revert to.
72
73All three stay inside the ownership gate: the note lists only the sender's
74apps, reads and updates check the manifest's `owner` against the requester,
75and other members' apps are invisible and untouchable.
76
77## The sandbox
78
79The proxy's `render` mounts:
80
81```
82<iframe src="/app-sandbox" sandbox="allow-scripts">
83```
84
85- **Opaque origin.** `sandbox` without `allow-same-origin` gives the document
86 a null origin: no cookies, no localStorage/IndexedDB of the instance
87 origin (where the session token and private key live), no DOM access to
88 the parent page, no popups, no top navigation.
89- **Own CSP, not the page's.** `/app-sandbox` is a real page (memberapps.go)
90 served with `default-src 'none'; connect-src 'none'; script-src
91 '<sha256>' 'unsafe-eval'; img-src data: blob:; style-src 'unsafe-inline';
92 frame-src 'none'; form-action 'none'`. No fetch/XHR/WebSocket/beacon to
93 anywhere β€” including our own server. The script-src is a **hash of the one
94 inline runtime, deliberately NOT a nonce**: a nonce is readable by
95 same-document JS (`document.querySelector('script').nonce`), and a nonced
96 `<script src>` bypasses the host allow-list β€” so hostile app code could
97 read the nonce and pull remote code / exfiltrate despite `connect-src
98 'none'`. A hash authorises exactly that one script and nothing the app can
99 mint, so every `<script src>` (and every other inline script) is refused.
100 (A `srcdoc` frame would have *inherited* the chat page's CSP; a real page
101 carries its own.)
102- **One trusted script.** The page inlines `web/static/sandbox-frame.js`
103 (its bytes are what the CSP hash covers). It receives `{ source, manifest,
104 seed, me, theme }` over the bridge, rewrites the module's imports into an
105 SDK shim (`transformModuleSource`), evals it (`'unsafe-eval'` is scoped to
106 this caged document), folds events with the app's real reducer, renders
107 into the frame's own DOM, and reports state back.
108- **The bridge is the only doorway.** A `MessageChannel` the host establishes
109 on the iframe's **`load` event** β€” an unforgeable signal that fires on every
110 genuine (re)load, unlike a `postMessage` "ready" which app code could spoof
111 to hijack the port. The frame verifies init comes from `window.parent` with
112 a real (non-null) origin, so one sandboxed app can't initialise a sibling.
113 Everything a frame sends is validated host-side (`web/static/sandbox.js`):
114 - **emit/schedule/ephemeral** β€” the event must be one of the app's own
115 manifest types (all prefixed `<name>.`), deep-cleaned to plain JSON (no
116 `__proto__`/`constructor` keys, depth- and size-capped), transport
117 fields (`name`/`ts`/`uid`) stripped, `notice` clamped to plain strings
118 with action chips removed, and rate-limited. A member app can never
119 spoof `chat.*`, another app's events, or a plain chat message.
120 - **state** β€” size-capped, JSON-normalized, installed only through
121 `installMemberAppState` (apps.js), which is gated on the proxy marker.
122
123What's inside the frame when it runs: the app's source, **its own thread's**
124decrypted events and folded state, the member's uid/screen name, and the
125theme variables. What is never inside: keys, tokens, other threads, the
126roster, message content from anywhere else. The worst a malicious member app
127can do is lie about or destroy *its own* state β€” the same power its reducer
128always had.
129
130Known residual channels, accepted and bounded: the frame can navigate itself
131back to `/app-sandbox` (a self-reload β€” it just resets its own cage, gaining
132nothing); cross-origin navigation and remote subresource loads are blocked by
133the parent's `frame-src 'self'` and the frame's own hash-pinned `script-src` +
134`connect-src 'none'` + `img-src data: blob:`. `postMessage` from app code is
135validated away (init rides the load event, not a message). Nothing touches the
136E2EE core: keys, tokens, other threads, and the parent DOM never enter the
137frame, so the worst a hostile app does is corrupt or spoof its *own* pane.
138
139## State: `{ v, vUpTo, pend }`
140
141The host never runs the untrusted reducer, but the framework's snapshot
142machinery needs *some* fold. The proxy's fold is a trusted generic queue:
143
144- every wire event of the app's types appends `{ e, meta }` to `pend`
145 (bounded; overflow drops oldest and flags `lost`);
146- the frame folds those events with the real reducer and reports
147 `{ state, upTo }`; the host swaps that in as `v`/`vUpTo` and trims `pend`.
148
149At every moment `v + pend` *is* the fold at the thread's horizon β€” the swap
150is horizon-neutral β€” so `takeSnapshot`/`seedThread` persist and restore
151sandboxed state through the standard encrypted `thread_state` path, and a
152closed app still accumulates events safely (they queue in `pend`, fold on
153next open). Scheduled events and `notice` pushes ride the normal wire paths
154untouched.
155
156## Server surface (`memberapps.go`)
157
158| route | gate | notes |
159|---|---|---|
160| `GET /api/member-apps` | member token | blobs included (only clients can decrypt) |
161| `PUT /api/member-apps/{id}` | member token | new id: any member (cap 200); existing: author, owner, or an agent asserting that same recorded owner |
162| `DELETE /api/member-apps/{id}` | author or admin | admin = moderation without readability |
163| `GET /app-sandbox` | none (static runtime) | the caged frame document |
164| `GET /api/member-app-guide` | none | the authoring guide the bot builds against |
165
166Admins can switch the whole tier off with the `member_apps_enabled` setting
167(Admin β†’ Features β†’ "Let members make their own apps", default on) β€” narrower
168than the `apps_enabled` gate, which covers the entire apps feature. Off means:
169the `PUT` above 403s (new deploys *and* edits), the guide 404s (which flips the
170bot sidecar's `create_app` builder off β€” it treats a failed guide fetch as
171"building disabled"), and App Studio hides from the app pickers (an
172already-open App Studio thread drops its editor and says why; the deployed-apps
173list stays, minus Edit code / Revert). Apps already deployed keep listing and
174running, and `DELETE` stays open β€” the gate stops new use, never cleanup or
175moderation.
176
177Ownership: the server's author is whoever deployed (the bot, for bot-built
178apps). The *requester* is recorded inside the encrypted manifest (`owner`),
179and the bot refuses to update an app for anyone but its requester β€” the gate
180lives where the plaintext lives. The server's overwrite gate mirrors that
181split: author or owner always may, and an **agent** that is neither may
182overwrite *on the recorded owner's behalf* by asserting that same `owner_id`
183(the visible half of the manifest check it already ran) β€” which is what lets
184a bot fix an app the member wrote in App Studio, or one a different bot built
185for them. Ownership never transfers via an overwrite, and owner-less legacy
186rows stay closed to agents. Residual: chat payloads aren't
187sender-authenticated (the thread key is symmetric β€” every member can encrypt
188one), so a determined member could assert another's uid to redirect an
189update, exactly as they could impersonate anyone in plain chat. That's a
190property of the messaging layer, not this gate; the blast radius is capped by
191the sandbox (a hijacked update only alters that app's own caged pane, never
192keys or data). What the gate does enforce firmly: an unknown requester (no
193uid) can't build or update anything, and an app with no recorded owner can't
194be updated by anyone β€” so it never silently opens.
195
196Caps: a global bound (200 apps) and a per-author bound (40) are enforced in
197one transaction so concurrent deploys can't race past them; the per-author
198bound stops one member starving the shared global cap.
199
200## App Studio β€” self-serve deploy (`web/static/apps/appstudio.js`)
201
202App Studio is the member-facing manager: a compiled-in **trusted** app (it
203holds the token + instance key to encrypt/deploy, so it can't be a sandboxed
204member app) that lists deployed apps and deploys pasted source directly β€” no
205bot. It's the same doorway the bot uses (`PUT /api/member-apps`), driven by a
206member instead. The privileged work lives in `web/static/sandbox.js`
207(`listDeployedApps`, `deployMemberApp`, `retireMemberApp`, `extractManifest`,
208`openMemberApp`); shell.js injects the token/instance-key/opener via
209`setMemberAdmin`.
210
211The load-bearing piece is **manifest extraction**: rather than re-parse source
212with regexes, App Studio runs the pasted code in a throwaway sandbox frame
213(`extract` mode in `sandbox-frame.js` β†’ `evalMemberApp` β†’ `manifestOf`) and
214reads back the *real* `defineApp` spec's name/title/glyph/types/commands/chat.
215Untrusted code is evaluated only in the cage, and the extractor is the
216authoritative source of truth (no drift with the runtime). `deployMemberApp`
217then validates that manifest, encrypts `{ source, manifest }` under the
218instance key, and PUTs it β€” minting a fresh id for a new app, or reusing the id
219to update one you own.
220
221A **new** app registers live on deploy (usable immediately, no reload) and
222suppresses this client's own `apps_changed` reload (`recentLocalDeploy`); an
223**update** changes already-loaded code, so it rides the normal reload.
224
225## Picking up a new app: offer, don't force
226
227When `apps_changed` arrives (a bot build, an App Studio deploy on another
228device, or an admin custom-app install), the client does NOT reload out from
229under you. It registers any new *member* apps live (so they work at once), then
230surfaces a subtle, dismissible **"A new app is available β€” Reload / Later"**
231nudge in the sidebar (`offerAppReload`, styled like the backup banner β€” inline,
232never a floating toast). Reload is the reliable pickup for every tier (custom
233apps are ES modules that need a fresh load); "Later" just hides it. The bot's
234deploy confirmation reinforces the same low-pressure framing.
235
236## Native
237
238Member apps ride the web shell, so the embedded webview (iOS/Mac render one
239thread per webview) runs them without native code: `bootEmbed` registers the
240proxies before rendering. A native Apps launcher entry is follow-up work;
241until then an app thread created on the web appears in the native thread
242list like any thread.
243
244## Commands, companion chat, and formatting (in the frame)
245
246Three capabilities cross the sandbox boundary by running the app's untrusted
247logic in the frame and letting the host act on the (validated) result:
248
249- **`../format.js`** β€” the pure text helpers are bundled *ahead of* the runtime
250 into the one `/app-sandbox` module (memberapps.go `sandboxBundle`), so a
251 member app's `import { richTokens } from "../format.js"` resolves with no
252 extra fetch. The CSP hash covers the whole bundle.
253- **Slash commands** β€” the manifest carries command *metadata* (names, for the
254 `/` typeahead); the handlers live in the source and run in a frame. When the
255 app's thread is open its live frame runs the command; otherwise the host
256 mounts a transient hidden frame (seeded from the snapshot) just to execute
257 it. The handler's `ctx.emit` bridges out; its `ctx.reply({text,short})` comes
258 back and shows as the private confirmation where the user typed, with a
259 host-owned "Open <app>" chip (the app can't ship clickable code into the
260 host). Bridge: hostβ†’frame `{type:"command",…}`, frameβ†’host
261 `{type:"command-reply",spec}`.
262- **Companion chat** β€” the manifest flags `chat:true` (derived by the bot from
263 the source's `chatMessage`); the proxy gains a `chatMessage` so the framework
264 treats it as a companion (Chat + Interface views). The real projection runs
265 in the frame: it computes `chatMessage(event)` on fold and reports the chat
266 lines alongside state (snapshotted in the proxy's `{v,vUpTo,pend,chat}` so the
267 Chat view survives the window). The host folds each line into the thread's
268 Chat view via the same `chat.msg` path a trusted companion uses, deduped by
269 server message id.
270
271All three stay inside the trust model: only the app's own thread data, its own
272manifest event types, and the projected/replied strings ever cross, all
273size-capped and rate-limited; keys, tokens, other threads, and the parent DOM
274never enter the frame.
275
276## Legacy formats: the compat policy
277
278Member-app blobs are instance-key-encrypted user content: the server can't
279read them, there is no census of what exists across (self-hosted, offline)
280instances, and only the OWNER's client may rewrite one. So superseded
281authoring vocabulary can't simply be dropped β€” but it doesn't get to sprawl
282either. The policy, in deprecation-ladder order:
283
2841. **Writers emit only the current vocabulary.** The bot's builder,
285 `manifestOf` (App Studio's extractor), and the `check-app.mjs` linter all
286 produce/teach the current form β€” nothing mints a legacy blob, ever.
2872. **Readers accept legacy at exactly ONE tested funnel per boundary** β€”
288 today: `normalizePlaces` in `defineApp` (the scope enum and the
289 personal/groupCapable/spaceCapable flags normalize into `places` and are
290 deleted) and the manifest→places derivation in `sandbox.js`. When a
291 concept is superseded, all first-party code migrates and the internal
292 reads are DELETED in the same change, so the funnel is the only place the
293 old vocabulary exists. (A concept that never reached the stored-manifest
294 boundary can be deleted outright with no funnel at all β€” `groupChatOnly`.)
2953. **Upgrades are opportunistic and owner-driven.** Any touch modernizes an
296 app: bot updates regenerate source against the current guide, and App
297 Studio flags legacy-format apps ("older format") with a one-tap
298 **Upgrade** β€” redeploying the same source re-mints a current manifest,
299 with the old version kept in History. Never rewrite a member's SOURCE
300 automatically: it's user content, and a transform bug bricks an app we
301 can't inspect.
3024. **The floor may eventually rise β€” but the accepted set is bounded and
303 written down, never infinite.** The full legacy set is currently:
304 manifests with `scope: "personal"` and/or the capability booleans in
305 place of `places`, and sources declaring the same fields in `defineApp`.
306 After a long, announced deprecation window (upgrade offers shipped well
307 before), `validateManifest` β€” the load gate β€” may refuse forms below the
308 floor; a refused app fails to register with a reason, exactly like any
309 invalid manifest, and its owner upgrades from App Studio. Raising the
310 floor is a deliberate, documented decision, never a side effect.
311
312## Follow-ups (deliberately out of scope)
313
314- `messageAction` / `notify` / `preview` / `unread` for sandboxed apps.
315- ~~Shared-scope (group) member apps~~ β€” SHIPPED since: a member app's
316 `places` may name any non-empty subset of you/chat/space, so chat-shared,
317 space-wide, and even space-only (gif-shaped, `listed: false`) member apps
318 are all legal (`docs/building-member-apps.md`).
319- An admin-console list of member apps (App Studio lists a member's own; an
320 admin can delete any via the API today).
321- In-place update of a running app without a reload (updates currently reload
322 to swap code).
323
324## πŸ€– Command panels (member apps)
325
326A member app's command may open a command panel (`ctx.panel` β€” the transient
327surface above the composer). The trust posture is unchanged:
328
329- The panel BODY is a fresh `/app-sandbox` frame β€” the same opaque-origin,
330 no-network, no-keys cage as the app's own surface. The host never runs the
331 app's panel code in the page.
332- Everything crosses the bridge as validated JSON: the request
333 (`sanitizePanelSpec` β€” clamped title, 64 KB data cap), emits from the panel
334 frame (the standard sanitize + rate limit), composer inserts (length-capped),
335 and content-height reports (clamped).
336- A panel opens only while one of the app's commands is actually running (the
337 host resolves the opener by the command's sequence number), so a frame can't
338 pop panels spontaneously.
339- The panel HEADER and βœ• are host-drawn, outside the frame β€” dismissal can't
340 be trapped. A panel frame's `state` reports are ignored; the app-thread's
341 own frame stays the single writer.
342
343## πŸ€– Platform limitations: what member apps structurally can't do (yet)
344
345Building a full pack of *real* apps (a 21-app "family" set;
346`web/static/apps-disabled/family/`) surfaced a consistent boundary worth
347recording so it isn't rediscovered each time: member apps are well-suited to
348**personal, cooperative, single-thread** tools (lists, trackers, journals,
349games) and structurally **can't express roles/authority, per-member privacy,
350targeted notification, or enforcement** β€” even though the *server* already holds
351the primitives for most of it.
352
353- **No authority reaches apps.** Access levels (tiers, `docs/community-and-tiers.md`)
354 are admin-assignable roster roles β€” not just paid β€” *each carrying its own
355 key*: a ready-made "parents/children" or "moderators/members" primitive with
356 cryptographic backing. But `ctx` exposes no tiers and the sandbox withholds the
357 roster, so an app can gate nothing on a role. (In the family pack, an "ask a
358 parent" app degrades to "anyone but the asker".)
359- **No per-member privacy in a thread.** One symmetric thread key β†’ every member
360 decrypts every event and snapshot. An app can only *render-hide* (a courtesy,
361 not a vault); asymmetric secrecy needs cryptographic sealing the keyless
362 sandbox can't perform.
363- **The fold can't authorise.** Pure reducers see only `e.uid`; every member can
364 emit every event, so "only X may do Y" is a render suggestion, never enforced.
365- **No roster, no targeting, no directed notice.** Apps rebuild a pseudo-roster
366 from participation (who has emitted an event); a `notice` reaches everyone who
367 receives the event, not a chosen person or role.
368- **Convergent fold vs asymmetry.** "The same events fold to the same state"
369 (load-bearing for snapshots + native parity + the event vectors) is exactly
370 what makes per-viewer or per-role state impossible except as a render illusion.
371
372**Widen-it path (roadmap, not built).** Expose tier membership to `ctx` for
373admin-defined **role gating** (Level 1 β€” courtesy, but backed by a real role);
374add host-mediated **seal-to-tier / seal-except** primitives for real enforcement
375and privacy (Level 2 β€” the keyless sandbox can't do it alone); give `notice` a
376target so an app can reach a person or a role. Fuller write-up, with per-app
377evidence and the three-level "who sees what" model, lives in the family
378reference pack's `DESIGN.md` (Β§ "Fit for purpose?" and Β§ "Viewer-aware
379rendering").
380