eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

πŸ€– Member apps: LLM-built or member-written, member-deployed, sandboxed

Members get an app two ways: ask a bot in plain chat ("build me a habit tracker"), or write the code themselves and deploy it from App Studio. Either way it lands in the Apps menu a moment later. This document is the design: what runs where, what the server can see, and why untrusted code can't touch anything that matters. The authoring rules live in docs/building-member-apps.md (served at /api/member-app-guide); the mini-app platform itself in web/static/apps/README.md.

Two trust tiers

| | custom apps (existing) | member apps (this doc) | |---|---|---| | who ships it | the instance admin | any member (usually via a bot) | | what it is | CODE the admin vetted | USER CONTENT | | stored as | plaintext JS (custom_apps) | instance-key-encrypted blob (member_apps) | | served as | an executable ES module | never β€” the server can't even read it | | runs | in the page, full trust | in an opaque-origin sandboxed iframe | | install gate | admin token | member token; author-only overwrite | | moderation | admin delete | author or admin delete |

The existing tier is unchanged. The new tier exists because "anyone can add code" and "code runs with access to everything a device can decrypt" cannot coexist β€” so member apps get a cage instead of a vetting step.

The loop, end to end

  1. Ask. A member tells a model-enabled bot what they want. The bot's system prompt teaches it to interview briefly, then call its create_app tool with a name/title/glyph/spec (internal/bot/brain.go).
  2. Build. The sidecar runs a dedicated generation call against the instance's own authoring guide (fetched from /api/member-app-guide, so bot and server can never disagree), then statically validates the module β€” allowed imports only, no network/storage APIs, one defineApp, namespaced event types β€” and regenerates on failure (internal/bot/appbuild.go). This validation is a quality gate; the sandbox below is the security boundary.
  3. Deploy. The bot β€” an ordinary E2EE member holding its own wrapped copy of the instance key β€” encrypts { source, manifest } under that key and PUTs the blob to /api/member-apps/{id} with its member token (memberapps.go). The id is random; the server learns nothing but (id, author, size, time). apps_changed broadcasts and clients reload.
  4. Launch. At boot the client fetches the list, decrypts each blob with the instance key, validates the manifest, and registers a proxy app through the ordinary defineApp (web/static/sandbox.js) β€” so the app appears in New chat's Apps list, gets its own personal thread, folds events, and snapshots exactly like a built-in. Opening it mounts the sandbox frame; the member sees their custom UI full-pane in the shell.

Fixing an existing app: the bot's eyes

Building is only half the loop β€” members come back with "the done button does nothing, fix it". The server can't help (it holds ciphertext), so the bot's sight is client-side, built from the same instance key it deploys with (internal/bot/appbuild.go):

  • Discovery. Every model reply carries a per-sender system note listing the apps that member owns β€” exact app_id, title, name, version, builder β€” decrypted from the deployed list (briefly cached; the sidecar's own deploys invalidate it). Without this, the model only ever knew an id that happened to appear in the recent conversation, and "fix my habit tracker" from a fresh chat silently became a blind second build.
  • Reading. A read_app_source tool returns one app's manifest + current source (owner-gated, exactly like updating), run in a short bounded tool loop inside the reply β€” so the bot can diagnose a bug or explain the app's behaviour before (or without) rebuilding.
  • Updating. create_app with the app_id from the note; the build call regenerates from the app's current source, so the fix is an edit, not a from-scratch rewrite. The deploy verifies and announces the new version, and the old one stays in App Studio's History to revert to.

All three stay inside the ownership gate: the note lists only the sender's apps, reads and updates check the manifest's owner against the requester, and other members' apps are invisible and untouchable.

The sandbox

The proxy's render mounts:

<iframe src="/app-sandbox" sandbox="allow-scripts">
  • Opaque origin. sandbox without allow-same-origin gives the document a null origin: no cookies, no localStorage/IndexedDB of the instance origin (where the session token and private key live), no DOM access to the parent page, no popups, no top navigation.
  • Own CSP, not the page's. /app-sandbox is a real page (memberapps.go) served with default-src 'none'; connect-src 'none'; script-src '<sha256>' 'unsafe-eval'; img-src data: blob:; style-src 'unsafe-inline'; frame-src 'none'; form-action 'none'. No fetch/XHR/WebSocket/beacon to anywhere β€” including our own server. The script-src is a hash of the one inline runtime, deliberately NOT a nonce: a nonce is readable by same-document JS (document.querySelector('script').nonce), and a nonced <script src> bypasses the host allow-list β€” so hostile app code could read the nonce and pull remote code / exfiltrate despite connect-src 'none'. A hash authorises exactly that one script and nothing the app can mint, so every <script src> (and every other inline script) is refused. (A srcdoc frame would have inherited the chat page's CSP; a real page carries its own.)
  • One trusted script. The page inlines web/static/sandbox-frame.js (its bytes are what the CSP hash covers). It receives { source, manifest, seed, me, theme } over the bridge, rewrites the module's imports into an SDK shim (transformModuleSource), evals it ('unsafe-eval' is scoped to this caged document), folds events with the app's real reducer, renders into the frame's own DOM, and reports state back.
  • The bridge is the only doorway. A MessageChannel the host establishes on the iframe's load event β€” an unforgeable signal that fires on every genuine (re)load, unlike a postMessage "ready" which app code could spoof to hijack the port. The frame verifies init comes from window.parent with a real (non-null) origin, so one sandboxed app can't initialise a sibling. Everything a frame sends is validated host-side (web/static/sandbox.js): - emit/schedule/ephemeral β€” the event must be one of the app's own manifest types (all prefixed <name>.), deep-cleaned to plain JSON (no __proto__/constructor keys, depth- and size-capped), transport fields (name/ts/uid) stripped, notice clamped to plain strings with action chips removed, and rate-limited. A member app can never spoof chat.*, another app's events, or a plain chat message. - state β€” size-capped, JSON-normalized, installed only through installMemberAppState (apps.js), which is gated on the proxy marker.

What's inside the frame when it runs: the app's source, its own thread's decrypted events and folded state, the member's uid/screen name, and the theme variables. What is never inside: keys, tokens, other threads, the roster, message content from anywhere else. The worst a malicious member app can do is lie about or destroy its own state β€” the same power its reducer always had.

Known residual channels, accepted and bounded: the frame can navigate itself back to /app-sandbox (a self-reload β€” it just resets its own cage, gaining nothing); cross-origin navigation and remote subresource loads are blocked by the parent's frame-src 'self' and the frame's own hash-pinned script-src + connect-src 'none' + img-src data: blob:. postMessage from app code is validated away (init rides the load event, not a message). Nothing touches the E2EE core: keys, tokens, other threads, and the parent DOM never enter the frame, so the worst a hostile app does is corrupt or spoof its own pane.

State: { v, vUpTo, pend }

The host never runs the untrusted reducer, but the framework's snapshot machinery needs some fold. The proxy's fold is a trusted generic queue:

  • every wire event of the app's types appends { e, meta } to pend (bounded; overflow drops oldest and flags lost);
  • the frame folds those events with the real reducer and reports { state, upTo }; the host swaps that in as v/vUpTo and trims pend.

At every moment v + pend is the fold at the thread's horizon β€” the swap is horizon-neutral β€” so takeSnapshot/seedThread persist and restore sandboxed state through the standard encrypted thread_state path, and a closed app still accumulates events safely (they queue in pend, fold on next open). Scheduled events and notice pushes ride the normal wire paths untouched.

Server surface (memberapps.go)

| route | gate | notes | |---|---|---| | GET /api/member-apps | member token | blobs included (only clients can decrypt) | | PUT /api/member-apps/{id} | member token | new id: any member (cap 200); existing: author, owner, or an agent asserting that same recorded owner | | DELETE /api/member-apps/{id} | author or admin | admin = moderation without readability | | GET /app-sandbox | none (static runtime) | the caged frame document | | GET /api/member-app-guide | none | the authoring guide the bot builds against |

Admins can switch the whole tier off with the member_apps_enabled setting (Admin β†’ Features β†’ "Let members make their own apps", default on) β€” narrower than the apps_enabled gate, which covers the entire apps feature. Off means: the PUT above 403s (new deploys and edits), the guide 404s (which flips the bot sidecar's create_app builder off β€” it treats a failed guide fetch as "building disabled"), and App Studio hides from the app pickers (an already-open App Studio thread drops its editor and says why; the deployed-apps list stays, minus Edit code / Revert). Apps already deployed keep listing and running, and DELETE stays open β€” the gate stops new use, never cleanup or moderation.

Ownership: the server's author is whoever deployed (the bot, for bot-built apps). The requester is recorded inside the encrypted manifest (owner), and the bot refuses to update an app for anyone but its requester β€” the gate lives where the plaintext lives. The server's overwrite gate mirrors that split: author or owner always may, and an agent that is neither may overwrite on the recorded owner's behalf by asserting that same owner_id (the visible half of the manifest check it already ran) β€” which is what lets a bot fix an app the member wrote in App Studio, or one a different bot built for them. Ownership never transfers via an overwrite, and owner-less legacy rows stay closed to agents. Residual: chat payloads aren't sender-authenticated (the thread key is symmetric β€” every member can encrypt one), so a determined member could assert another's uid to redirect an update, exactly as they could impersonate anyone in plain chat. That's a property of the messaging layer, not this gate; the blast radius is capped by the sandbox (a hijacked update only alters that app's own caged pane, never keys or data). What the gate does enforce firmly: an unknown requester (no uid) can't build or update anything, and an app with no recorded owner can't be updated by anyone β€” so it never silently opens.

Caps: a global bound (200 apps) and a per-author bound (40) are enforced in one transaction so concurrent deploys can't race past them; the per-author bound stops one member starving the shared global cap.

App Studio β€” self-serve deploy (web/static/apps/appstudio.js)

App Studio is the member-facing manager: a compiled-in trusted app (it holds the token + instance key to encrypt/deploy, so it can't be a sandboxed member app) that lists deployed apps and deploys pasted source directly β€” no bot. It's the same doorway the bot uses (PUT /api/member-apps), driven by a member instead. The privileged work lives in web/static/sandbox.js (listDeployedApps, deployMemberApp, retireMemberApp, extractManifest, openMemberApp); shell.js injects the token/instance-key/opener via setMemberAdmin.

The load-bearing piece is manifest extraction: rather than re-parse source with regexes, App Studio runs the pasted code in a throwaway sandbox frame (extract mode in sandbox-frame.js β†’ evalMemberApp β†’ manifestOf) and reads back the real defineApp spec's name/title/glyph/types/commands/chat. Untrusted code is evaluated only in the cage, and the extractor is the authoritative source of truth (no drift with the runtime). deployMemberApp then validates that manifest, encrypts { source, manifest } under the instance key, and PUTs it β€” minting a fresh id for a new app, or reusing the id to update one you own.

A new app registers live on deploy (usable immediately, no reload) and suppresses this client's own apps_changed reload (recentLocalDeploy); an update changes already-loaded code, so it rides the normal reload.

Picking up a new app: offer, don't force

When apps_changed arrives (a bot build, an App Studio deploy on another device, or an admin custom-app install), the client does NOT reload out from under you. It registers any new member apps live (so they work at once), then surfaces a subtle, dismissible "A new app is available β€” Reload / Later" nudge in the sidebar (offerAppReload, styled like the backup banner β€” inline, never a floating toast). Reload is the reliable pickup for every tier (custom apps are ES modules that need a fresh load); "Later" just hides it. The bot's deploy confirmation reinforces the same low-pressure framing.

Native

Member apps ride the web shell, so the embedded webview (iOS/Mac render one thread per webview) runs them without native code: bootEmbed registers the proxies before rendering. A native Apps launcher entry is follow-up work; until then an app thread created on the web appears in the native thread list like any thread.

Commands, companion chat, and formatting (in the frame)

Three capabilities cross the sandbox boundary by running the app's untrusted logic in the frame and letting the host act on the (validated) result:

  • ../format.js β€” the pure text helpers are bundled ahead of the runtime into the one /app-sandbox module (memberapps.go sandboxBundle), so a member app's import { richTokens } from "../format.js" resolves with no extra fetch. The CSP hash covers the whole bundle.
  • Slash commands β€” the manifest carries command metadata (names, for the / typeahead); the handlers live in the source and run in a frame. When the app's thread is open its live frame runs the command; otherwise the host mounts a transient hidden frame (seeded from the snapshot) just to execute it. The handler's ctx.emit bridges out; its ctx.reply({text,short}) comes back and shows as the private confirmation where the user typed, with a host-owned "Open <app>" chip (the app can't ship clickable code into the host). Bridge: hostβ†’frame {type:"command",…}, frameβ†’host {type:"command-reply",spec}.
  • Companion chat β€” the manifest flags chat:true (derived by the bot from the source's chatMessage); the proxy gains a chatMessage so the framework treats it as a companion (Chat + Interface views). The real projection runs in the frame: it computes chatMessage(event) on fold and reports the chat lines alongside state (snapshotted in the proxy's {v,vUpTo,pend,chat} so the Chat view survives the window). The host folds each line into the thread's Chat view via the same chat.msg path a trusted companion uses, deduped by server message id.

All three stay inside the trust model: only the app's own thread data, its own manifest event types, and the projected/replied strings ever cross, all size-capped and rate-limited; keys, tokens, other threads, and the parent DOM never enter the frame.

Legacy formats: the compat policy

Member-app blobs are instance-key-encrypted user content: the server can't read them, there is no census of what exists across (self-hosted, offline) instances, and only the OWNER's client may rewrite one. So superseded authoring vocabulary can't simply be dropped β€” but it doesn't get to sprawl either. The policy, in deprecation-ladder order:

  1. Writers emit only the current vocabulary. The bot's builder, manifestOf (App Studio's extractor), and the check-app.mjs linter all produce/teach the current form β€” nothing mints a legacy blob, ever.
  2. Readers accept legacy at exactly ONE tested funnel per boundary — today: normalizePlaces in defineApp (the scope enum and the personal/groupCapable/spaceCapable flags normalize into places and are deleted) and the manifest→places derivation in sandbox.js. When a concept is superseded, all first-party code migrates and the internal reads are DELETED in the same change, so the funnel is the only place the old vocabulary exists. (A concept that never reached the stored-manifest boundary can be deleted outright with no funnel at all — groupChatOnly.)
  3. Upgrades are opportunistic and owner-driven. Any touch modernizes an app: bot updates regenerate source against the current guide, and App Studio flags legacy-format apps ("older format") with a one-tap Upgrade β€” redeploying the same source re-mints a current manifest, with the old version kept in History. Never rewrite a member's SOURCE automatically: it's user content, and a transform bug bricks an app we can't inspect.
  4. The floor may eventually rise β€” but the accepted set is bounded and written down, never infinite. The full legacy set is currently: manifests with scope: "personal" and/or the capability booleans in place of places, and sources declaring the same fields in defineApp. After a long, announced deprecation window (upgrade offers shipped well before), validateManifest β€” the load gate β€” may refuse forms below the floor; a refused app fails to register with a reason, exactly like any invalid manifest, and its owner upgrades from App Studio. Raising the floor is a deliberate, documented decision, never a side effect.

Follow-ups (deliberately out of scope)

  • messageAction / notify / preview / unread for sandboxed apps.
  • ~~Shared-scope (group) member apps~~ β€” SHIPPED since: a member app's places may name any non-empty subset of you/chat/space, so chat-shared, space-wide, and even space-only (gif-shaped, listed: false) member apps are all legal (docs/building-member-apps.md).
  • An admin-console list of member apps (App Studio lists a member's own; an admin can delete any via the API today).
  • In-place update of a running app without a reload (updates currently reload to swap code).

πŸ€– Command panels (member apps)

A member app's command may open a command panel (ctx.panel β€” the transient surface above the composer). The trust posture is unchanged:

  • The panel BODY is a fresh /app-sandbox frame β€” the same opaque-origin, no-network, no-keys cage as the app's own surface. The host never runs the app's panel code in the page.
  • Everything crosses the bridge as validated JSON: the request (sanitizePanelSpec β€” clamped title, 64 KB data cap), emits from the panel frame (the standard sanitize + rate limit), composer inserts (length-capped), and content-height reports (clamped).
  • A panel opens only while one of the app's commands is actually running (the host resolves the opener by the command's sequence number), so a frame can't pop panels spontaneously.
  • The panel HEADER and βœ• are host-drawn, outside the frame β€” dismissal can't be trapped. A panel frame's state reports are ignored; the app-thread's own frame stays the single writer.

πŸ€– Platform limitations: what member apps structurally can't do (yet)

Building a full pack of real apps (a 21-app "family" set; web/static/apps-disabled/family/) surfaced a consistent boundary worth recording so it isn't rediscovered each time: member apps are well-suited to personal, cooperative, single-thread tools (lists, trackers, journals, games) and structurally can't express roles/authority, per-member privacy, targeted notification, or enforcement β€” even though the server already holds the primitives for most of it.

  • No authority reaches apps. Access levels (tiers, docs/community-and-tiers.md) are admin-assignable roster roles β€” not just paid β€” each carrying its own key: a ready-made "parents/children" or "moderators/members" primitive with cryptographic backing. But ctx exposes no tiers and the sandbox withholds the roster, so an app can gate nothing on a role. (In the family pack, an "ask a parent" app degrades to "anyone but the asker".)
  • No per-member privacy in a thread. One symmetric thread key β†’ every member decrypts every event and snapshot. An app can only render-hide (a courtesy, not a vault); asymmetric secrecy needs cryptographic sealing the keyless sandbox can't perform.
  • The fold can't authorise. Pure reducers see only e.uid; every member can emit every event, so "only X may do Y" is a render suggestion, never enforced.
  • No roster, no targeting, no directed notice. Apps rebuild a pseudo-roster from participation (who has emitted an event); a notice reaches everyone who receives the event, not a chosen person or role.
  • Convergent fold vs asymmetry. "The same events fold to the same state" (load-bearing for snapshots + native parity + the event vectors) is exactly what makes per-viewer or per-role state impossible except as a render illusion.

Widen-it path (roadmap, not built). Expose tier membership to ctx for admin-defined role gating (Level 1 β€” courtesy, but backed by a real role); add host-mediated seal-to-tier / seal-except primitives for real enforcement and privacy (Level 2 β€” the keyless sandbox can't do it alone); give notice a target so an app can reach a person or a role. Fuller write-up, with per-app evidence and the three-level "who sees what" model, lives in the family reference pack's DESIGN.md (Β§ "Fit for purpose?" and Β§ "Viewer-aware rendering").