eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

๐Ÿค– Space-wide apps: install once, use everywhere

The problem this solves: an app you build (or love) shouldn't need adding to every chat, by every person, before it's useful. If someone makes a sticker library, "install it for everyone in the space" should mean exactly that โ€” its slash commands work in any chat, its data is one shared library, and nobody else installs anything.

The design in one paragraph

A space-wide install is the group-app model one level up. Every instance gets (lazily, on the first install) a single hidden space thread โ€” a kind:"space" thread with implicit membership (every member of the space, by construction) whose AES key rides open_key, i.e. encrypted under the instance key that every member already unwraps with their own private key (the open-group mechanism). Which apps are installed is fold state from spaceapps.add / spaceapps.remove events in that thread's encrypted log โ€” the groupapps registry's twin โ€” and each installed app runs one shared instance whose events and state snapshots live in the same thread. Commands, @mentions, the sidebar's space band, scheduling, and snapshots all reuse the existing per-thread machinery; only the thread is new.

What the server learns (and doesn't)

Nothing changes about rule one. The server stores: one thread row of kind space, one wrapped key blob it cannot open, and opaque ciphertext. It never learns which apps are installed (that's inside the log), what any app's data is, or who uses what. The two server-visible facts are that a space thread exists and its message timings/sizes โ€” the same metadata class as any thread.

  • No new key material class. The user-facing question "does this mean a second key per user?" has the answer no: members unwrap the instance key with their personal key (existing path, late-joiner distribution and all), and the instance key unwraps the space thread's key. The invariant "the instance key never encrypts messages" holds โ€” it only wraps the space thread's key, exactly as it wraps every open group's.
  • Implicit membership (db.threadMember / db.memberUserIDs): every non-agent member is a member of the space thread with no rows to backfill and no join path that can drift. Agents are excluded โ€” a bot reading the space log must be a deliberate, disclosed choice (an explicit membership row can be added later), mirroring joinDefaultThreads.
  • Broadcast goes to everyone, and that's correct: every member can decrypt by design, so WS fan-out to all members leaks nothing. Push is skipped for the space thread (pushNewMessage / pushScheduled): registry churn and app traffic must not buzz every phone, and clients that don't know the thread couldn't render the notification. Live WS delivery still applies, so open clients stay current; a space app that wants notification-grade voice is a future, deliberate design.
  • Invisible to old clients. threadsForUser joins on membership rows, so the space thread never appears in GET /api/threads โ€” web clients that predate this, and the native apps until they learn the feature, never see it. New clients fetch it explicitly (GET /api/space-thread).
  • The break-glass instance-key reset deletes it. Unlike an open group, members hold no per-member wraps for the space thread โ€” open_key under the instance key is the only path to its key โ€” so after a reset its log is unreadable forever. resetInstanceKey deletes the thread (journaled, inside the same transaction so a racing create can't slip past), its messages, state, schedules, and blobs; the next install starts fresh. One residual: a tab left open across the reset still holds the dead instance key in memory and could re-mint the space thread wrapped under it (the server can't tell a live wrap from a dead one). If that happens, the scoped POST /api/admin/space-thread/reset clears it without further collateral.

Server surface

| capability | endpoint | notes | | ---------- | -------- | ----- | | Fetch | GET /api/space-thread?token= | {id, open_key}, or {} before first install. Member-gated; agents get {} unless explicitly made members โ€” a sidecar holds the instance key, so handing it open_key would unwrap the very key it was excluded from (defence in depth on top of the message gate). | | Create | POST /api/space-thread {token, open_key} | Find-or-create; idempotent under races (a partial unique index allows exactly one kind='space' row, and the stored open_key is never overwritten โ€” the grantInstanceKey anti-brick rule). Broadcasts a content-free space_thread WS prod so online clients fetch it live. | | Reset | POST /api/admin/space-thread/reset | Admin break-glass for a wedged space thread โ€” chiefly a poisoned open_key: any member can create the thread and the blob is never overwritten (and can't be validated server-side), so a hostile or buggy first write would otherwise wedge space apps for good. Deletes only the space thread + its rows (journaled); the next install mints a fresh one. Scoped on purpose โ€” recovery mustn't cost every member their sealed name/photo the way the full instance-key reset does. |

Everything else โ€” messages, state snapshots, scheduling, uploads โ€” is the ordinary thread machinery, gated by the (now implicit) membership check. POST /api/threads refuses kind:"space", and the rename/avatar endpoints refuse the space thread (it renders nowhere โ€” implicit membership must not open a scribble surface). The one space thread is minted only through the endpoint above. Server code: spacethread.go; tests: spacethread_test.go.

Client model

  • places including "space" on defineApp (or in a member-app manifest, where the legacy spaceCapable: true boolean is also accepted and normalizes into it) marks an app installable for the space โ€” an explicit trust decision per app, like the "chat" place, with the same bar: a pure, idempotent, validating reducer, render off ctx.threadId, per-user logic keyed on e.uid. No shipped app currently declares it (ยง The pullback below).
  • ONE registry app โ€” installs (web/static/apps.js) โ€” folds every shared place's membership: groupapps.* events in a group's log, spaceapps.* in the space thread's (the legacy wire families stay the wire format forever โ€” logs are immutable; the unification is in the fold, and legacy snapshots seed the unified key via seedThread's shim). Read through spaceAppsFor() (filtered to registered, space-capable apps, so hostile or stale installs stay inert) and spaceAppInfo(name) (the "Installed by Ana" attribution). Vectors: test/event-vectors/spaceapps.json.
  • Command routing precedence (runCommand): local verbs โ†’ the app's own thread โ†’ this chat's group instance โ†’ the space instance โ†’ your personal thread. A space-installed app's global command acts on the shared space instance from every chat โ€” that's what "installed for everyone" means โ€” while the same verb typed inside the app's own personal thread, or in a group that added the app, keeps addressing the instance you're looking at (the nearest scope). The / typeahead discloses the routing, reading on from the command's desc as one sentence: "Post a sticker for this space" (placeRowPhrase).
  • /app @everyone [text] installs for the space in one gesture (the space-wide sibling of /todo @here), creating the space thread on first use; trailing text runs against the fresh shared instance. Non-space-capable apps get a private explanation, never a mis-filed event. @space is an accepted alias, and @me completes the family toward privacy: it files into your own copy even where a shared one claims the bare verb. When several copies are reachable, the / typeahead offers the flagged variants as extra rows โ€” the menu teaches the keywords. The fresh case (nothing placed anywhere) gets the same treatment: the flags that would install the app ride as rows that say so in the same sentence shape โ€” "/todo @here" reads "Add a to-do to a new To-do app for this group" (groups only), and "/todo @everyone" the space twin (hidden when the admins-only policy bars you) โ€” while rows for copies that already exist read plain routing ("Add a to-do for this space"), no install language (installRowPhrase / placeRowPhrase). Alternate rows always rank right behind their bare verb, so an install offer never outranks the default route.
  • @mentions: "@Stickers party time" works in any chat once the app is space-installed; the event lands in the space log, panels and replies stay where you typed.
  • The Apps sheet (โ‹ฎ โ†’ Apps in any chat โ€” DMs too โ€” and from the SPACE's own sidebar menu; an OPEN chat counts as "This chat" whichever door you came through) is ONE directory with a FOCUS-AND-EXPLAIN picker: every app appears once, wearing badges for the places it already occupies ("This chat" / "Everyone") and a โ€บ that focuses it โ€” the other rows drop away, and the app grows three constant place SELECTORS (Just for you | This chat | Everyone โ€” the sentence's and the badges' own words; the verb lives only on the footer action) over an explanation panel. The selection starts on the chat place when this chat could hold the app, else the personal one. Every selector always shows; a place that can't act here is dimmed but stays clickable, and tapping it makes the panel say exactly WHY (the app's own placeNote, an inferred reason for credential-backed apps, "no chat behind this menu", the admins-only policy). Available places put the real action in the panel โ€” Open, Add, or Remove with "Added for everyone by Ana" attribution โ€” so reading and doing are one motion. One verb โ€” "Add" โ€” because the act is placement, not software installation. Groups can also start life with apps: the New-group sheet's "Start with apps" picks land as ordinary groupapps.add events in the fresh log (parity with native's group-birth picker). The / typeahead ranks commands by the nearness of the place their data lands: this chat's, the space's, then everything else.
  • The space band: every space-installed app with a render gets its own SIDEBAR ROW (badged "space", subtitle "For everyone in this space"), opening the app full-pane bound to the space thread โ€” the copy's home, and its only surface. Space apps deliberately do not ride other chats' widget bars: the same toggle meaning "this chat's private list" in one chat and "the whole space's" in another put the disambiguation burden on the reader (place = privacy must also mean place = location). The row carries live activity (the owning app's preview/unread hooks fold off the space log) and the home's header repeats the orientation, so the one-time orientation card now exists only for CHAT copies ("shared with everyone in this chat, and only here"). The twins problem โ€” a personal "To-do" sitting right beside the space's โ€” is met with three reinforcing cues: the space row is named after the space ("Lazyatom To-do"; spaceRowTitle, falling back to "Space To-do" for unset or over-long names, header included); its tile pairs the space photo and the app glyph as equal-sized circles on the diagonal โ€” neither identity subordinate (words still carry the state); and on a collision the personal row's generic "app" chip gives way to a "Just for you" place badge โ€” the Apps sheet's own selector words โ€” so both twins name their side.
  • Replies name their place: a command routed away from where you typed is stamped with the place it acted in (runCommand โ†’ the notice's muted footer, in the same words the typeahead uses โ€” placePhrase). Apps can additionally word it themselves via ctx.place ("Added โ€ฆ to the space to-do list" / "I'll remind everyone in this space: โ€ฆ"); the framework stamp never depends on that. ctx.open ("View to-do") shows the instance โ€” the space home, a chat copy's Interface pane, or the personal app-thread โ€” never a raw navigation to the space thread, which has no chat surface.
  • Who may add for everyone is an admin policy (Admin app โ†’ Features โ†’ "Space-wide apps: admins only"; default: any member, attributed โ€” the group social contract one level up). The registry is E2EE fold state the server can't referee, so enforcement is client-side and honest about it: the flag is plaintext config served as page meta; the sheet and @everyone refuse for non-admins ("Only admins can add apps for everyone in this space"), registry items record the adder's uid, and spaceAppsFor() filters at READ time โ€” a non-admin's install is inert while the policy holds, and reappears if it's lifted (read-time keeps folds deterministic and the policy reversible; pre-uid items are grandfathered). A hostile client can still emit an add event; what it cannot do is make anyone else's client honor it.
  • Boot: loadSpaceThread() (after the instance key + member apps; retried from onInstanceKeyReady) fetches, unwraps, and seeds the space thread's snapshot + catch-up, so routing and the sidebar's space band see installs before the first keystroke. Live spaceapps/app events over WS fold and refresh the visible surfaces; a space_thread prod announces first creation.

The sticker-library walkthrough

  1. A member asks a bot to build a stickers app; the bot deploys it as a member app with spaceCapable: true in its manifest (sandboxed, instance-key encrypted โ€” nothing new).
  2. The member runs /stickers @everyone (or uses โ‹ฎ โ†’ Apps โ†’ Install). The space thread is created, the install event folds for every member, and /stickers appears in everyone's / typeahead in every chat.
  3. Anyone, in any chat, types /stickers โ€” a command panel opens right there; uploads/additions emit events into the space log, so the library is one shared, end-to-end-encrypted collection.
  4. Posting a sticker into the current chat is an ordinary encrypted attachment in that chat's thread (panel.attachFiles โ€” the /gif pattern): app data lives space-wide, conversation content stays under the conversation's own key.

The pullback (2026-07-15): places UI hidden, machinery live

The multi-place surfaces read as too much, too soon for members โ€” the crisp failure test: "if there's two To-do apps in the thread list, we've failed." So the user-facing places UI is pulled back, deliberately WITHOUT reverting the machinery, because the questions it answers (one app, several homes; space-wide installs; the flag grammar) are still live design work:

  • Shipped apps park at you+chat. To-do, Reminders, Bookmarks, and the Vault declare places: ["you", "chat"] โ€” no shipped app can stand in the sidebar as both a personal thread and a space row, which is the twins failure. Nothing else enforces this: an app returns to the space place by adding "space" back to its one places line (every downstream surface โ€” typeahead flag rows, the Apps sheet, the space band โ€” follows the declaration). /gif stays ["space"]: implicitly placed, listed: false, no visible surface.
  • PLACES_UI (shellstate.js) gates the multi-place chrome, default OFF, read from localStorage.lchat_places_ui ("1" = on, per device, reload to apply) โ€” a client-side experiment switch, deliberately not a server gate. With it off: the ๏ผ‹ picker's app rows go back to the direct personal open/create (no places-sheet doorway); a chat's โ‹ฎ โ†’ Apps opens the simple chat-only sheet (manageChatApps โ€” add/remove shared copies for THIS chat, groups only, so the item hides in DMs); and the space menu's Apps entry hides. With it on, the full focus-and-explain directory and every doorway come back exactly as designed above.
  • What stays on regardless: chat placement everywhere it lived before the places work (โ‹ฎ โ†’ Apps in groups, "Start with apps" at group birth, @here, the typeahead's @here install row), the @me escape hatch, and the whole space machinery (thread, registry, routing, vectors) โ€” exercised by the synthetic all-places fixtures in test/apps.test.mjs (triple, stickers) so it can't rot while parked.

Scope and follow-ups

  • Web + server first (deliberate). The native apps neither see the space thread (it's absent from /api/threads) nor route commands to it yet; bringing iOS (first) and Mac to parity โ€” native spaceapps folding, the sheet section, catalog entries, and SpaceAppsVectorTests twins โ€” is the next slice. The web vectors in test/event-vectors/spaceapps.json are the contract they'll pin against.
  • Offline-first space state: the space thread's ciphertext isn't in the local store's boot snapshot yet, so a cold offline boot โ€” and the first beat of a cached boot, before loadSpaceThread lands โ€” routes commands personally. The / typeahead label always tells the truth about where a command routes right now, so the window is disclosed, but extending store.js hydration to the space thread is the follow-up that closes it.
  • Removal visibility: group adds/removes show as action lines in the conversation; space installs show only in the Apps sheet ("Installed by โ€ฆ"). A future "space activity" surface (or Admin-app note) could make removals more visible.
  • 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 โ€” e.g. a stickers bot that curates the shared library.