Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
at main 321 lines 21 kB view raw view rendered
1# Architecture 2 3## Project Structure 4 5Monorepo using Bun workspaces. Each module (feeds, polls, forms, etc.) is an independent package with its own backend routes and frontend components. Modules share common infrastructure but can be developed and deployed independently. 6 7``` 8exosphere/ 9├── packages/ 10│ ├── core/ # Shared server-side infrastructure 11│ │ ├── auth/ # AT Protocol OAuth, session management 12│ │ ├── db/ # SQLite setup, base migrations 13│ │ └── types/ # Common types and Zod schemas 14│ ├── client/ # Shared client-side utilities 15│ │ └── src/ # API helpers, auth, router, hooks, theme, styles, types 16│ ├── indexer/ # Jetstream consumer & shared module registry 17│ │ ├── src/ 18│ │ │ ├── modules.ts # Shared module list (used by app + standalone) 19│ │ │ ├── start.ts # startJetstream() — WebSocket consumer 20│ │ │ ├── cursor.ts # Cursor persistence for resume-after-crash 21│ │ │ └── main.ts # Standalone entry point 22│ ├── <module>/ # Feed & discussions module 23│ │ ├── api/ # Hono routes 24│ │ ├── ui/ # Preact page components 25│ │ └── schemas/ # Module-specific types and schemas 26│ └── app/ # Host app — assembles selected modules 27├── bunfig.toml 28└── package.json 29``` 30 31The `app` package is the host: it imports the desired modules, mounts their routes and UI, and produces the final deployable artifact. A Sphere operator picks which modules to enable. 32 33## Stack 34 35### Backend 36 37- **Runtime**: Bun 38- **Framework**: Hono.js 39- **Database**: SQLite (via `bun:sqlite`) 40- **Auth**: AT Protocol OAuth via `@atproto/*` packages 41 42### Frontend 43 44- **Framework**: Preact 45- **State management**: Preact Signals 46- **Styling**: vanilla-extract 47 48### Shared (core) 49 50- **Language**: TypeScript with strict types 51- **Validation**: Zod schemas at system boundaries 52 53## Module Design 54 55Each module: 56 57- Exposes a Hono sub-app for its API routes 58- Exposes Preact components for its UI 59- Manages its own SQLite tables (migrations scoped per module) 60- Can depend on `core` and `client` but not on other modules 61 62### Server / Client Separation 63 64Backend and frontend code must stay in separate entry points to avoid bundling server-only dependencies (e.g. `bun:sqlite`) into the browser bundle. 65 66Each module exposes two entry points in its `package.json` exports: 67 68| Export | File | Purpose | 69| ------------ | --------------- | ---------------------------------------------------------------------------------- | 70| `"."` | `src/index.ts` | Backend — `ExosphereModule` with Hono API routes. Imported by `app/src/server.ts`. | 71| `"./client"` | `src/client.ts` | Frontend — `ClientModule` with page routes. Imported by `app/src/app.tsx`. | 72 73`ExosphereModule` (backend) and `ClientModule` (frontend) are independent types — `ClientModule` does not extend `ExosphereModule` to prevent any transitive import of server code. 74 75### Client Module and Route Registration 76 77The `@exosphere/client` package (`packages/client`) provides shared client-side utilities (API helpers, auth state, router, hooks, theme, styles) and the `ClientModule` type: 78 79```typescript 80interface ModuleRoute { 81 path: string; // e.g. "/feeds" or "/feature-requests/:number" 82 component: ComponentType; 83} 84 85interface ClientModule { 86 name: string; 87 routes?: ModuleRoute[]; 88} 89``` 90 91Each module's `src/client.ts` exports a `ClientModule` that declares its routes and page components. The host app collects all client modules into an array and maps them to `<Route>` elements inside a preact-iso `<Router>` — adding a new module's pages requires only importing its `ClientModule` and appending it to the array. 92 93### Server-Side Rendering (SSR) 94 95The app server-renders pages so the browser receives ready-to-display HTML. SSR runs in both dev (via a Vite plugin) and production (Bun serves pre-built assets). 96 97#### How it works 98 991. **Template** — the built `index.html` is loaded once at startup. In production, all CSS from the Vite manifest is injected as render-blocking `<link>` tags to prevent FOUC. 1002. **Data prefetch** — for each request, the server resolves auth (from the `sid` cookie), loads the current Sphere, and calls its own API routes internally (`app.request()`) to prefetch page data. Dependent prefetches (e.g. comments after a feature request) run sequentially. 1013. **Render**`entry-server.tsx` sets Preact signals (`auth`, `sphereState`, `ssrPageData`) from the prefetched data, then calls `preact-iso/prerender` to produce HTML. 1024. **Hydration** — the HTML and serialized `__SSR_DATA__` are injected into the template. The client reads `__SSR_DATA__` to populate signals before hydration, so components skip their initial fetch via `useQuery`'s `initialData` option. 103 104#### Eager vs lazy imports 105 106Module routes use `lazy()` (from `preact-iso`) on the client for code splitting. During SSR, lazy components throw promises that the router can't resolve. Each module therefore exposes a `./client-ssr` entry with direct (eager) imports of the same page components. `entry-server.tsx` imports these eager modules; the client entry uses the default lazy ones. 107 108| Entry point | Module imports | Code splitting | 109| ---------------------- | --------------- | -------------- | 110| `src/client.tsx` | Lazy (`lazy()`) | Yes | 111| `src/entry-server.tsx` | Eager (direct) | N/A | 112 113#### Dev vs production 114 115- **Dev** — a Vite plugin (`vite-ssr-plugin.ts`) intercepts page requests before the SPA fallback. It fetches auth/sphere/page data from the running API server (`localhost:3001`) via HTTP, SSR-renders via `server.ssrLoadModule`, and inlines CSS collected from the module graph. 116- **Production** — the Hono catch-all route in `server.ts` handles SSR. The template and CSS links are prepared once at startup. Data prefetch uses `app.request()` to call API routes in-process (no HTTP round-trip). 117- Both environments share the same prefetch logic (`ssr-prefetch.ts`) — only the transport differs (HTTP in dev, in-process in prod). 118 119## AT Protocol Integration 120 121- Authentication via AT Protocol OAuth (handled in `core/auth`) 122- User data (posts, votes, etc.) is written to each user's PDS 123- The backend indexes relevant data from PDS for fast querying 124- Sphere configuration and membership are published on PDS for interoperability (see below) 125- The local SQLite database caches/indexes all PDS data for fast querying 126- Every record type written to a user's PDS has a formal Lexicon schema definition hosted at `landing/.well-known/` 127 128### Lexicon schemas 129 130All AT Protocol record types are defined as Lexicon JSON files: 131 132| Lexicon ID | Published by | Purpose | 133| ------------------------------------------ | ------------ | ------------------------------------------------------------------------ | 134| `site.exosphere.sphere` | Sphere owner | Sphere declaration (name, slug, visibility, modules) — enables discovery | 135| `site.exosphere.sphereMember` | Member | "I am a member of this Sphere" — member-side of bilateral membership | 136| `site.exosphere.sphereMemberApproval` | Owner/admin | "This user is an approved member" — admin-side of bilateral membership | 137| `site.exosphere.featureRequest` | Author | Feature request content | 138| `site.exosphere.featureRequestVote` | Voter | Upvote on a feature request | 139| `site.exosphere.featureRequestComment` | Commenter | Comment on a feature request | 140| `site.exosphere.featureRequestCommentVote` | Voter | Upvote on a comment | 141| `site.exosphere.featureRequestStatus` | Admin/owner | Status change on a feature request | 142| `site.exosphere.moderation` | Admin/owner | Moderation action on any content (e.g. comment removal) | 143 144## Data Ownership 145 146- Logged-in users own their content via their PDS 147- The local SQLite database acts as a cache/index, not the source of truth for user content 148- Sphere configuration lives on the owner's PDS, membership is bilateral (both parties publish records) 149- A third party can reconstruct any public Sphere entirely from PDS data — no dependency on a specific Exosphere instance 150 151### PDS as source of truth 152 153For public Spheres, **no meaningful action should exist only in the local database**. Every user action (creating content, voting, commenting) and every admin action (status changes, moderation, role updates) must be represented as an AT Protocol record on someone's PDS. The local SQLite database indexes these records for fast querying, but it is always rebuildable from PDS data. 154 155This means: 156 157- **User actions** are published on the **user's PDS** (the user owns their data). 158- **Admin actions** that affect other users' content (e.g. removing a comment, changing a feature request's status) are published on the **admin's PDS** — not by modifying or deleting the original author's record. The admin has no authority over another user's PDS repo. 159- **Moderation** follows this pattern: when an admin removes content, they publish a `site.exosphere.moderation` record on their own PDS referencing the content's AT URI. The original content stays on the author's PDS. The local DB marks the content as hidden for fast filtering, but the moderation decision is reconstructable from protocol data. 160 161If a piece of state exists only in SQLite with no corresponding PDS record, it cannot survive a database rebuild and is invisible to third-party indexers. The exception is **private Spheres**, where content intentionally stays off-protocol (AT Protocol repos are public by design). 162 163## Deployment 164 165- Docker container for self-hosting 166- Single image containing both backend and frontend (backend serves static frontend assets) 167 168## Sphere Access Models 169 170A Sphere can be configured as private or public, each with different data storage and access patterns. 171 172### Sphere declaration on PDS 173 174When a Sphere is created, the owner publishes a `site.exosphere.sphere` record on their PDS. This makes the Sphere discoverable by anyone crawling the AT Protocol network and serves as the canonical reference that other records (membership, content) point to via AT URI. 175 176### Private Spheres 177 178Private Spheres are invitation-only. All content is hidden from the public. 179 180- **Authentication**: Required. Users must authenticate with their AT Protocol DID. 181- **Data storage**: All content (posts, reactions, comments, polls, etc.) is stored in the Sphere's local SQLite database — not on users' PDS. AT Protocol repos are public by design, so private content must stay off-protocol. 182- **Sphere record**: The Sphere declaration is still published on the owner's PDS (with `visibility: "private"`), making it discoverable but not its content. 183- **Identity**: Users are still identified by their AT Protocol DID, which provides portable identity and verification without exposing content to the public network. 184 185``` 186User (authenticated via AT Proto OAuth) 187 → Sphere API (checks membership) 188 → SQLite (read/write private data) 189``` 190 191### Public Spheres 192 193Public Spheres content is readable by anyone. Write access depends on the Sphere's configuration. 194 195#### Authentication 196 197All interactions (posts, reactions, comments) require AT Protocol authentication. Users log in via AT Protocol OAuth and their content is written to their PDS as records using Exosphere's Lexicon schemas. This makes user data portable and user-owned. 198 199Public Spheres are publicly **readable** without authentication — anyone with the URL can view the content. But all **write** actions require a DID. 200 201#### Future: unauthenticated write access 202 203Unauthenticated write access (e.g. anonymous poll votes, guest comments) is deferred to a later phase. It would allow users without an AT Protocol account to interact with specific modules, potentially helping adoption by lowering the entry barrier. 204 205However, it introduces a hybrid data architecture: the AppView would need to merge data from two sources (user PDS records and local SQLite records) into a single unified view. This adds complexity to every query path and leaks into every module's implementation. 206 207When revisited, unauthenticated write should be: 208 209- Opt-in per module (not a core concern) 210- Limited to modules where it clearly adds value (polls, forms) rather than applied broadly 211- Designed with account linking in mind (what happens when an anonymous user later signs up) 212 213#### Write access modes 214 215- **Open**: Any authenticated user can participate (react, comment, post). 216- **Members only**: Public read, permissioned write. Anyone can view the content, but only invited members can interact (react, comment, post). 217 218Important: AT Protocol cannot prevent a user from writing records to their own PDS that reference a Sphere's content. Write restrictions are enforced at the AppView level — only interactions from approved members are indexed and displayed. Non-member interactions are silently ignored. 219 220## AppView and Data Consumption 221 222The AppView is the service that consumes, indexes, and serves AT Protocol data for a Sphere. It is the layer where access control and data aggregation happen. 223 224### Jetstream 225 226Jetstream is a lightweight alternative to the full AT Protocol firehose. Instead of decoding binary CBOR/CAR data, it provides a WebSocket stream of JSON events. 227 228- The AppView connects to a Jetstream instance and subscribes to events matching Exosphere's Lexicon record types. 229- Jetstream can be consumed from Bluesky's public instances, or self-hosted for full independence. 230- Self-hosting Jetstream also requires running a relay. For most deployments, using a public Jetstream instance is sufficient — the Sphere still self-hosts all indexing, storage, and access control. 231 232### Indexing pipeline 233 234The indexer lives in its own package (`packages/indexer`) and can run either **in-process** with the API server or as a **standalone service**. Both modes use the same `startJetstream()` function and share the same module registry (`packages/indexer/src/modules.ts`). 235 236#### Deployment modes 237 238| Mode | Command | Description | 239| ---------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------- | 240| **Combined** (default) | `bun run start` | API server starts the indexer in-process. Simplest deployment — one process, one start command. | 241| **API only** | `DISABLE_INDEXER=1 bun run start` | API server without the Jetstream consumer. Use when the indexer runs separately. | 242| **Indexer only** | `bun run start:indexer` | Standalone Jetstream consumer. Useful for scaling or isolating the indexer from the API. | 243 244The shared module registry (`@exosphere/indexer/modules`) is the single source of truth for which modules are loaded. Both the API server and the standalone indexer import from it, so adding a new module requires updating only one file. 245 246#### Event flow 247 248For Public Spheres with authenticated users, the AppView indexes data from the AT Protocol network: 249 250``` 251Jetstream (WebSocket, filtered by Exosphere Lexicons) 252 → Event received (e.g. a reaction record created on a user's PDS) 253 → Is this record referencing a known Sphere? → No → discard 254 → Is this Sphere open or is this user a member? → No → discard 255 → Index the record into SQLite 256 → Available via Sphere API 257``` 258 259For Private Spheres and unauthenticated data, there is no Jetstream consumption. Data is written directly to the Sphere database through the API: 260 261``` 262User → Sphere API (auth + membership check) → SQLite 263``` 264 265### Data flow summary 266 267| Sphere type | Data written to | Data read from | Jetstream needed | 268| ----------- | --------------- | ---------------------- | ---------------- | 269| Private | Sphere SQLite | Sphere SQLite | No | 270| Public | User's PDS | AppView index (SQLite) | Yes | 271 272## Membership Model 273 274Membership uses a **bilateral model** inspired by AT Protocol follows. Both the Sphere admin and the member publish records on their respective PDS, creating a verifiable two-sided proof of membership. The local SQLite database indexes these records for fast access control checks. 275 276### Bilateral membership records 277 278Membership is represented by two complementary AT Protocol records: 279 2801. **`site.exosphere.sphereMemberApproval`** — published on the **owner/admin's PDS** when they invite or approve a member. Contains the Sphere AT URI, the member's DID, and their role. 2812. **`site.exosphere.sphereMember`** — published on the **member's PDS** when they accept the invitation. Contains the Sphere AT URI. 282 283Both records must exist for a membership to be considered fully established. This mirrors how AT Protocol handles social relationships (e.g. follows) and enables third-party indexers to reconstruct the full membership graph from PDS data alone. 284 285### Local index (SQLite) 286 287The `sphere_members` table caches membership state for fast access control: 288 289- **DID**: The user's AT Protocol decentralized identifier 290- **Role**: Owner, admin, member (extensible per Sphere needs) 291- **Status**: Active, invited, revoked 292- **Invited by**: DID of the user who sent the invitation 293- **Joined at**: Timestamp 294 295This table is the authoritative source for real-time access control decisions (API checks, indexer filtering). It is kept in sync with PDS records but allows the server to make fast membership lookups without querying the network. 296 297### Invitation flow 298 2991. A Sphere admin invites a user by their AT Protocol handle or DID. 3002. The handle is resolved to a DID (if needed). 3013. The admin publishes a `sphereMemberApproval` record on their PDS. 3024. An invitation record is created in the local membership table (status: invited). 3035. The invited user accepts via the Sphere UI. 3046. On acceptance, the user publishes a `sphereMember` record on their PDS. 3057. Local status is set to active and the user can participate according to the Sphere's write access mode. 306 307### Private Spheres and membership privacy 308 309For **private Spheres**, the Sphere record itself (`site.exosphere.sphere`) has `visibility: "private"`. The bilateral membership records are still published on PDS (since AT Protocol repos are public), but the Sphere's content remains off-protocol. A third party could see that a user is a member of a private Sphere, but cannot access the Sphere's content. This is an acceptable trade-off — membership is public, content is private — similar to how a private GitHub repository's collaborator list can be partially visible. 310 311If full membership privacy is required, operators can skip PDS writes for membership and rely solely on the local SQLite table. This sacrifices interoperability for privacy. 312 313### Enforcement points 314 315| Layer | Role | 316| --------------------- | -------------------------------------------------------------------------------------------------------------- | 317| **AppView / Indexer** | Filters incoming Jetstream events. Only indexes interactions from active members (for members-only Spheres). | 318| **Sphere API** | Checks membership on write requests. Rejects unauthorized actions before they reach the database. | 319| **Client UI** | Hides write controls (comment box, reaction buttons) for non-members. UX convenience, not a security boundary. | 320 321Membership checks happen at both the API level (for direct writes) and the indexer level (for PDS records arriving via Jetstream). Both paths must enforce the same rules.