eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1# ๐Ÿค– Space-wide apps: install once, use everywhere
2
3The problem this solves: an app you build (or love) shouldn't need adding to
4every chat, by every person, before it's useful. If someone makes a sticker
5library, "install it for everyone in the space" should mean exactly that โ€” its
6slash commands work in any chat, its data is one shared library, and nobody
7else installs anything.
8
9## The design in one paragraph
10
11A space-wide install is the group-app model **one level up**. Every instance
12gets (lazily, on the first install) a single hidden **space thread** โ€” a
13`kind:"space"` thread with **implicit membership** (every member of the space,
14by construction) whose AES key rides `open_key`, i.e. encrypted under the
15**instance key** that every member already unwraps with their own private key
16(the open-group mechanism). Which apps are installed is fold state from
17`spaceapps.add` / `spaceapps.remove` events in that thread's encrypted log โ€”
18the `groupapps` registry's twin โ€” and each installed app runs **one shared
19instance** whose events and state snapshots live in the same thread. Commands,
20@mentions, the sidebar's space band, scheduling, and snapshots all reuse the
21existing per-thread machinery; only the thread is new.
22
23## What the server learns (and doesn't)
24
25Nothing changes about rule one. The server stores: one thread row of kind
26`space`, one wrapped key blob it cannot open, and opaque ciphertext. It never
27learns **which** apps are installed (that's inside the log), what any app's
28data is, or who uses what. The two server-visible facts are that a space
29thread exists and its message timings/sizes โ€” the same metadata class as any
30thread.
31
32- **No new key material class.** The user-facing question "does this mean a
33 second key per user?" has the answer *no*: members unwrap the instance key
34 with their personal key (existing path, late-joiner distribution and all),
35 and the instance key unwraps the space thread's key. The invariant "the
36 instance key never encrypts messages" holds โ€” it only *wraps* the space
37 thread's key, exactly as it wraps every open group's.
38- **Implicit membership** (`db.threadMember` / `db.memberUserIDs`): every
39 non-agent member is a member of the space thread with no rows to backfill
40 and no join path that can drift. **Agents are excluded** โ€” a bot reading the
41 space log must be a deliberate, disclosed choice (an explicit membership row
42 can be added later), mirroring `joinDefaultThreads`.
43- **Broadcast goes to everyone, and that's correct**: every member can decrypt
44 by design, so WS fan-out to all members leaks nothing. **Push is skipped**
45 for the space thread (`pushNewMessage` / `pushScheduled`): registry churn
46 and app traffic must not buzz every phone, and clients that don't know the
47 thread couldn't render the notification. Live WS delivery still applies, so
48 open clients stay current; a space app that wants notification-grade voice
49 is a future, deliberate design.
50- **Invisible to old clients.** `threadsForUser` joins on membership rows, so
51 the space thread never appears in `GET /api/threads` โ€” web clients that
52 predate this, and the native apps until they learn the feature, never see
53 it. New clients fetch it explicitly (`GET /api/space-thread`).
54- **The break-glass instance-key reset deletes it.** Unlike an open group,
55 members hold no per-member wraps for the space thread โ€” `open_key` under the
56 instance key is the *only* path to its key โ€” so after a reset its log is
57 unreadable forever. `resetInstanceKey` deletes the thread (journaled, inside
58 the same transaction so a racing create can't slip past), its messages,
59 state, schedules, and blobs; the next install starts fresh. One residual: a
60 tab left open **across** the reset still holds the dead instance key in
61 memory and could re-mint the space thread wrapped under it (the server
62 can't tell a live wrap from a dead one). If that happens, the scoped
63 `POST /api/admin/space-thread/reset` clears it without further collateral.
64
65## Server surface
66
67| capability | endpoint | notes |
68| ---------- | -------- | ----- |
69| 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). |
70| 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. |
71| 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. |
72
73Everything else โ€” messages, state snapshots, scheduling, uploads โ€” is the
74ordinary thread machinery, gated by the (now implicit) membership check.
75`POST /api/threads` refuses `kind:"space"`, and the rename/avatar endpoints
76refuse the space thread (it renders nowhere โ€” implicit membership must not
77open a scribble surface). The one space thread is minted only through the
78endpoint above. Server code: `spacethread.go`; tests: `spacethread_test.go`.
79
80## Client model
81
82- **`places` including `"space"`** on `defineApp` (or in a member-app
83 manifest, where the legacy `spaceCapable: true` boolean is also accepted
84 and normalizes into it) marks an app installable for the space โ€” an
85 explicit trust decision per app, like the `"chat"` place, with the same
86 bar: a pure, idempotent, validating reducer, render off `ctx.threadId`,
87 per-user logic keyed on `e.uid`. No shipped app currently declares it
88 (ยง The pullback below).
89- **ONE registry app** โ€” `installs` (`web/static/apps.js`) โ€” folds every
90 shared place's membership: `groupapps.*` events in a group's log,
91 `spaceapps.*` in the space thread's (the legacy wire families stay the wire
92 format forever โ€” logs are immutable; the unification is in the fold, and
93 legacy snapshots seed the unified key via seedThread's shim). Read through
94 `spaceAppsFor()` (filtered to registered, space-capable apps, so hostile or
95 stale installs stay inert) and `spaceAppInfo(name)` (the "Installed by Ana"
96 attribution). Vectors: `test/event-vectors/spaceapps.json`.
97- **Command routing precedence** (`runCommand`): *local verbs โ†’ the app's own
98 thread โ†’ this chat's group instance โ†’ the space instance โ†’ your personal
99 thread*. A space-installed app's global command acts on the shared space
100 instance **from every chat** โ€” that's what "installed for everyone" means โ€”
101 while the same verb typed *inside* the app's own personal thread, or in a
102 group that added the app, keeps addressing the instance you're looking at
103 (the nearest scope). The `/` typeahead discloses the routing, reading on
104 from the command's desc as one sentence: "Post a sticker for this space"
105 (`placeRowPhrase`).
106- **`/app @everyone [text]`** installs for the space in one gesture (the
107 space-wide sibling of `/todo @here`), creating the space thread on first
108 use; trailing text runs against the fresh shared instance. Non-space-capable
109 apps get a private explanation, never a mis-filed event. `@space` is an
110 accepted alias, and **`@me`** completes the family toward privacy: it files
111 into your own copy even where a shared one claims the bare verb. When
112 several copies are reachable, the `/` typeahead offers the flagged variants
113 as extra rows โ€” the menu teaches the keywords. The fresh case (nothing
114 placed anywhere) gets the same treatment: the flags that would *install*
115 the app ride as rows that say so in the same sentence shape โ€” "/todo @here"
116 reads "Add a to-do to a new To-do app for this group" (groups only), and
117 "/todo @everyone" the space twin (hidden when the admins-only policy bars
118 you) โ€” while rows for copies that already exist read plain routing ("Add a
119 to-do for this space"), no install language (`installRowPhrase` /
120 `placeRowPhrase`). Alternate rows always rank right behind their bare verb,
121 so an install offer never outranks the default route.
122- **@mentions**: "@Stickers party time" works in any chat once the app is
123 space-installed; the event lands in the space log, panels and replies stay
124 where you typed.
125- **The Apps sheet** (โ‹ฎ โ†’ Apps in any chat โ€” DMs too โ€” and from the SPACE's
126 own sidebar menu; an OPEN chat counts as "This chat" whichever door you
127 came through) is ONE directory with a FOCUS-AND-EXPLAIN picker: every
128 app appears once, wearing badges for the places it already occupies ("This
129 chat" / "Everyone") and a โ€บ that focuses it โ€” the other rows drop away, and
130 the app grows three constant place SELECTORS (Just for you | This chat |
131 Everyone โ€” the sentence's and the badges' own words; the verb lives only on
132 the footer action) over an explanation panel. The selection starts on the
133 chat place when this chat could hold the app, else the personal one. Every
134 selector always shows; a place that can't act here is dimmed but stays
135 clickable, and tapping it makes the panel say exactly WHY (the app's own
136 placeNote, an inferred reason for credential-backed apps, "no chat behind
137 this menu", the admins-only policy). Available places put the real action
138 in the panel โ€” Open, Add, or Remove with "Added for everyone by Ana"
139 attribution โ€” so reading and doing are one motion. One verb โ€” "Add" โ€”
140 because the act is placement, not software installation. Groups can also
141 start life with apps: the New-group sheet's "Start with apps" picks land as
142 ordinary groupapps.add events in the fresh log (parity with native's
143 group-birth picker). The / typeahead ranks commands by the nearness of the
144 place their data lands: this chat's, the space's, then everything else.
145- **The space band**: every space-installed app with a `render` gets its own
146 SIDEBAR ROW (badged "space", subtitle "For everyone in this space"), opening
147 the app full-pane bound to the space thread โ€” the copy's *home*, and its
148 only surface. Space apps deliberately do **not** ride other chats' widget
149 bars: the same toggle meaning "this chat's private list" in one chat and
150 "the whole space's" in another put the disambiguation burden on the reader
151 (place = privacy must also mean place = location). The row carries live
152 activity (the owning app's `preview`/`unread` hooks fold off the space log)
153 and the home's header repeats the orientation, so the one-time orientation
154 card now exists only for CHAT copies ("shared with everyone in this chat,
155 and only here"). **The twins problem** โ€” a personal "To-do" sitting right
156 beside the space's โ€” is met with three reinforcing cues: the space row is
157 *named after the space* ("Lazyatom To-do"; `spaceRowTitle`, falling back to
158 "Space To-do" for unset or over-long names, header included); its tile
159 pairs the *space photo* and the app glyph as equal-sized circles on the
160 diagonal โ€” neither identity subordinate (words still carry the state);
161 and on a collision the personal row's generic "app" chip
162 gives way to a "Just for you" place badge โ€” the Apps sheet's own selector
163 words โ€” so both twins name their side.
164- **Replies name their place**: a command routed away from where you typed is
165 stamped with the place it acted in (`runCommand` โ†’ the notice's muted
166 footer, in the same words the typeahead uses โ€” `placePhrase`). Apps can
167 additionally word it themselves via `ctx.place` ("Added โ€ฆ to the space
168 to-do list" / "I'll remind everyone in this space: โ€ฆ"); the framework stamp
169 never depends on that. `ctx.open` ("View to-do") shows the *instance* โ€”
170 the space home, a chat copy's Interface pane, or the personal app-thread โ€”
171 never a raw navigation to the space thread, which has no chat surface.
172- **Who may add for everyone** is an admin policy (Admin app โ†’ Features โ†’
173 "Space-wide apps: admins only"; default: any member, attributed โ€” the group
174 social contract one level up). The registry is E2EE fold state the server
175 can't referee, so enforcement is client-side and honest about it: the flag
176 is plaintext config served as page meta; the sheet and `@everyone` refuse
177 for non-admins ("Only admins can add apps for everyone in this space"),
178 registry items record the adder's `uid`, and `spaceAppsFor()` filters at
179 READ time โ€” a non-admin's install is inert while the policy holds, and
180 reappears if it's lifted (read-time keeps folds deterministic and the
181 policy reversible; pre-uid items are grandfathered). A hostile client can
182 still emit an add event; what it cannot do is make anyone else's client
183 honor it.
184- **Boot**: `loadSpaceThread()` (after the instance key + member apps; retried
185 from `onInstanceKeyReady`) fetches, unwraps, and seeds the space thread's
186 snapshot + catch-up, so routing and the sidebar's space band see installs
187 before the first keystroke. Live `spaceapps`/app events over WS fold and
188 refresh the visible surfaces; a `space_thread` prod announces first creation.
189
190## The sticker-library walkthrough
191
1921. A member asks a bot to build a stickers app; the bot deploys it as a member
193 app with `spaceCapable: true` in its manifest (sandboxed, instance-key
194 encrypted โ€” nothing new).
1952. The member runs `/stickers @everyone` (or uses โ‹ฎ โ†’ Apps โ†’ Install). The
196 space thread is created, the install event folds for every member, and
197 `/stickers` appears in everyone's `/` typeahead in every chat.
1983. Anyone, in any chat, types `/stickers` โ€” a command panel opens right there;
199 uploads/additions emit events into the space log, so the library is one
200 shared, end-to-end-encrypted collection.
2014. Posting a sticker into the current chat is an ordinary encrypted attachment
202 in *that* chat's thread (`panel.attachFiles` โ€” the `/gif` pattern): app
203 data lives space-wide, conversation content stays under the conversation's
204 own key.
205
206## The pullback (2026-07-15): places UI hidden, machinery live
207
208The multi-place surfaces read as too much, too soon for members โ€” the crisp
209failure test: *"if there's two To-do apps in the thread list, we've failed."*
210So the user-facing places UI is pulled back, deliberately WITHOUT reverting
211the machinery, because the questions it answers (one app, several homes;
212space-wide installs; the flag grammar) are still live design work:
213
214- **Shipped apps park at `you`+`chat`.** To-do, Reminders, Bookmarks, and the
215 Vault declare `places: ["you", "chat"]` โ€” no shipped app can stand in the
216 sidebar as both a personal thread and a space row, which is the twins
217 failure. Nothing else enforces this: an app returns to the space place by
218 adding `"space"` back to its one `places` line (every downstream surface โ€”
219 typeahead flag rows, the Apps sheet, the space band โ€” follows the
220 declaration). `/gif` stays `["space"]`: implicitly placed, `listed: false`,
221 no visible surface.
222- **`PLACES_UI` (shellstate.js) gates the multi-place chrome**, default OFF,
223 read from `localStorage.lchat_places_ui` (`"1"` = on, per device, reload to
224 apply) โ€” a client-side experiment switch, deliberately not a server gate.
225 With it off: the ๏ผ‹ picker's app rows go back to the direct personal
226 open/create (no places-sheet doorway); a chat's โ‹ฎ โ†’ Apps opens the simple
227 chat-only sheet (`manageChatApps` โ€” add/remove shared copies for THIS chat,
228 groups only, so the item hides in DMs); and the space menu's Apps entry
229 hides. With it on, the full focus-and-explain directory and every doorway
230 come back exactly as designed above.
231- **What stays on regardless**: chat placement everywhere it lived before the
232 places work (โ‹ฎ โ†’ Apps in groups, "Start with apps" at group birth, `@here`,
233 the typeahead's `@here` install row), the `@me` escape hatch, and the whole
234 space machinery (thread, registry, routing, vectors) โ€” exercised by the
235 synthetic all-places fixtures in `test/apps.test.mjs` (`triple`, `stickers`)
236 so it can't rot while parked.
237
238## Scope and follow-ups
239
240- **Web + server first (deliberate).** The native apps neither see the space
241 thread (it's absent from `/api/threads`) nor route commands to it yet;
242 bringing iOS (first) and Mac to parity โ€” native `spaceapps` folding, the
243 sheet section, catalog entries, and `SpaceAppsVectorTests` twins โ€” is the
244 next slice. The web vectors in `test/event-vectors/spaceapps.json` are the
245 contract they'll pin against.
246- **Offline-first space state**: the space thread's ciphertext isn't in the
247 local store's boot snapshot yet, so a cold offline boot โ€” and the first
248 beat of a cached boot, before `loadSpaceThread` lands โ€” routes commands
249 personally. The `/` typeahead label always tells the truth about where a
250 command routes *right now*, so the window is disclosed, but extending
251 store.js hydration to the space thread is the follow-up that closes it.
252- **Removal visibility**: group adds/removes show as action lines in the
253 conversation; space installs show only in the Apps sheet ("Installed by โ€ฆ").
254 A future "space activity" surface (or Admin-app note) could make removals
255 more visible.
256- **Bots in the space thread**: excluded by default; a deliberate opt-in flow
257 (disclosed, like adding a bot to a group) would let a bot serve space-wide
258 apps โ€” e.g. a stickers bot that curates the shared library.
259