···11+# Public URL where the app is served (no trailing slash)
22+# Use 127.0.0.1 (not localhost) so the browser treats the PDS OAuth redirect
33+# as cross-site — the PDS rejects same-site sec-fetch-site headers.
44+PUBLIC_URL=http://127.0.0.1:5173
55+66+# Local PDS URL for development (optional)
77+# When set, enables local dev mode: loopback OAuth, handle resolution via PDS,
88+# and URL rewriting from the PDS hostname to localhost.
99+PDS_URL=http://localhost:3000
1010+1111+# ES256 private key in JWK format for OAuth client authentication
1212+# Only needed in production (local dev uses loopback auth with no key).
1313+# Generate one with: bun run packages/core/scripts/generate-key.ts
1414+# OAUTH_PRIVATE_KEY='{"kty":"EC","use":"sig","crv":"P-256","x":"...","y":"...","d":"..."}'
+18
.gitignore
···11+node_modules/
22+dist/
33+.env
44+!.env.example
55+66+# Local PDS
77+pds/pds.env
88+99+# SQLite
1010+*.sqlite
1111+*.sqlite-shm
1212+*.sqlite-wal
1313+1414+# Claude Code
1515+.claude/
1616+1717+# OSes
1818+.DS_Store
+321
ARCHITECTURE.md
···11+# Architecture
22+33+## Project Structure
44+55+Monorepo 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.
66+77+```
88+exosphere/
99+├── packages/
1010+│ ├── core/ # Shared server-side infrastructure
1111+│ │ ├── auth/ # AT Protocol OAuth, session management
1212+│ │ ├── db/ # SQLite setup, base migrations
1313+│ │ └── types/ # Common types and Zod schemas
1414+│ ├── client/ # Shared client-side utilities
1515+│ │ └── src/ # API helpers, auth, router, hooks, theme, styles, types
1616+│ ├── indexer/ # Jetstream consumer & shared module registry
1717+│ │ ├── src/
1818+│ │ │ ├── modules.ts # Shared module list (used by app + standalone)
1919+│ │ │ ├── start.ts # startJetstream() — WebSocket consumer
2020+│ │ │ ├── cursor.ts # Cursor persistence for resume-after-crash
2121+│ │ │ └── main.ts # Standalone entry point
2222+│ ├── <module>/ # Feed & discussions module
2323+│ │ ├── api/ # Hono routes
2424+│ │ ├── ui/ # Preact page components
2525+│ │ └── schemas/ # Module-specific types and schemas
2626+│ └── app/ # Host app — assembles selected modules
2727+├── bunfig.toml
2828+└── package.json
2929+```
3030+3131+The `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.
3232+3333+## Stack
3434+3535+### Backend
3636+3737+- **Runtime**: Bun
3838+- **Framework**: Hono.js
3939+- **Database**: SQLite (via `bun:sqlite`)
4040+- **Auth**: AT Protocol OAuth via `@atproto/*` packages
4141+4242+### Frontend
4343+4444+- **Framework**: Preact
4545+- **State management**: Preact Signals
4646+- **Styling**: vanilla-extract
4747+4848+### Shared (core)
4949+5050+- **Language**: TypeScript with strict types
5151+- **Validation**: Zod schemas at system boundaries
5252+5353+## Module Design
5454+5555+Each module:
5656+5757+- Exposes a Hono sub-app for its API routes
5858+- Exposes Preact components for its UI
5959+- Manages its own SQLite tables (migrations scoped per module)
6060+- Can depend on `core` and `client` but not on other modules
6161+6262+### Server / Client Separation
6363+6464+Backend and frontend code must stay in separate entry points to avoid bundling server-only dependencies (e.g. `bun:sqlite`) into the browser bundle.
6565+6666+Each module exposes two entry points in its `package.json` exports:
6767+6868+| Export | File | Purpose |
6969+| ------------ | --------------- | ---------------------------------------------------------------------------------- |
7070+| `"."` | `src/index.ts` | Backend — `ExosphereModule` with Hono API routes. Imported by `app/src/server.ts`. |
7171+| `"./client"` | `src/client.ts` | Frontend — `ClientModule` with page routes. Imported by `app/src/app.tsx`. |
7272+7373+`ExosphereModule` (backend) and `ClientModule` (frontend) are independent types — `ClientModule` does not extend `ExosphereModule` to prevent any transitive import of server code.
7474+7575+### Client Module and Route Registration
7676+7777+The `@exosphere/client` package (`packages/client`) provides shared client-side utilities (API helpers, auth state, router, hooks, theme, styles) and the `ClientModule` type:
7878+7979+```typescript
8080+interface ModuleRoute {
8181+ path: string; // e.g. "/feeds" or "/feature-requests/:number"
8282+ component: ComponentType;
8383+}
8484+8585+interface ClientModule {
8686+ name: string;
8787+ routes?: ModuleRoute[];
8888+}
8989+```
9090+9191+Each 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.
9292+9393+### Server-Side Rendering (SSR)
9494+9595+The 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).
9696+9797+#### How it works
9898+9999+1. **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.
100100+2. **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.
101101+3. **Render** — `entry-server.tsx` sets Preact signals (`auth`, `sphereState`, `ssrPageData`) from the prefetched data, then calls `preact-iso/prerender` to produce HTML.
102102+4. **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.
103103+104104+#### Eager vs lazy imports
105105+106106+Module 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.
107107+108108+| Entry point | Module imports | Code splitting |
109109+| ---------------------- | --------------- | -------------- |
110110+| `src/client.tsx` | Lazy (`lazy()`) | Yes |
111111+| `src/entry-server.tsx` | Eager (direct) | N/A |
112112+113113+#### Dev vs production
114114+115115+- **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.
116116+- **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).
117117+- Both environments share the same prefetch logic (`ssr-prefetch.ts`) — only the transport differs (HTTP in dev, in-process in prod).
118118+119119+## AT Protocol Integration
120120+121121+- Authentication via AT Protocol OAuth (handled in `core/auth`)
122122+- User data (posts, votes, etc.) is written to each user's PDS
123123+- The backend indexes relevant data from PDS for fast querying
124124+- Sphere configuration and membership are published on PDS for interoperability (see below)
125125+- The local SQLite database caches/indexes all PDS data for fast querying
126126+- Every record type written to a user's PDS has a formal Lexicon schema definition in `packages/core/src/lexicons/`
127127+128128+### Lexicon schemas
129129+130130+All AT Protocol record types are defined as Lexicon JSON files:
131131+132132+| Lexicon ID | Published by | Purpose |
133133+| ------------------------------------------ | ------------ | ------------------------------------------------------------------------ |
134134+| `site.exosphere.sphere` | Sphere owner | Sphere declaration (name, slug, visibility, modules) — enables discovery |
135135+| `site.exosphere.sphereMember` | Member | "I am a member of this Sphere" — member-side of bilateral membership |
136136+| `site.exosphere.sphereMemberApproval` | Owner/admin | "This user is an approved member" — admin-side of bilateral membership |
137137+| `site.exosphere.featureRequest` | Author | Feature request content |
138138+| `site.exosphere.featureRequestVote` | Voter | Upvote on a feature request |
139139+| `site.exosphere.featureRequestComment` | Commenter | Comment on a feature request |
140140+| `site.exosphere.featureRequestCommentVote` | Voter | Upvote on a comment |
141141+| `site.exosphere.featureRequestStatus` | Admin/owner | Status change on a feature request |
142142+| `site.exosphere.moderation` | Admin/owner | Moderation action on any content (e.g. comment removal) |
143143+144144+## Data Ownership
145145+146146+- Logged-in users own their content via their PDS
147147+- The local SQLite database acts as a cache/index, not the source of truth for user content
148148+- Sphere configuration lives on the owner's PDS, membership is bilateral (both parties publish records)
149149+- A third party can reconstruct any public Sphere entirely from PDS data — no dependency on a specific Exosphere instance
150150+151151+### PDS as source of truth
152152+153153+For 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.
154154+155155+This means:
156156+157157+- **User actions** are published on the **user's PDS** (the user owns their data).
158158+- **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.
159159+- **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.
160160+161161+If 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).
162162+163163+## Deployment
164164+165165+- Docker container for self-hosting
166166+- Single image containing both backend and frontend (backend serves static frontend assets)
167167+168168+## Sphere Access Models
169169+170170+A Sphere can be configured as private or public, each with different data storage and access patterns.
171171+172172+### Sphere declaration on PDS
173173+174174+When 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.
175175+176176+### Private Spheres
177177+178178+Private Spheres are invitation-only. All content is hidden from the public.
179179+180180+- **Authentication**: Required. Users must authenticate with their AT Protocol DID.
181181+- **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.
182182+- **Sphere record**: The Sphere declaration is still published on the owner's PDS (with `visibility: "private"`), making it discoverable but not its content.
183183+- **Identity**: Users are still identified by their AT Protocol DID, which provides portable identity and verification without exposing content to the public network.
184184+185185+```
186186+User (authenticated via AT Proto OAuth)
187187+ → Sphere API (checks membership)
188188+ → SQLite (read/write private data)
189189+```
190190+191191+### Public Spheres
192192+193193+Public Spheres content is readable by anyone. Write access depends on the Sphere's configuration.
194194+195195+#### Authentication
196196+197197+All 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.
198198+199199+Public Spheres are publicly **readable** without authentication — anyone with the URL can view the content. But all **write** actions require a DID.
200200+201201+#### Future: unauthenticated write access
202202+203203+Unauthenticated 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.
204204+205205+However, 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.
206206+207207+When revisited, unauthenticated write should be:
208208+209209+- Opt-in per module (not a core concern)
210210+- Limited to modules where it clearly adds value (polls, forms) rather than applied broadly
211211+- Designed with account linking in mind (what happens when an anonymous user later signs up)
212212+213213+#### Write access modes
214214+215215+- **Open**: Any authenticated user can participate (react, comment, post).
216216+- **Members only**: Public read, permissioned write. Anyone can view the content, but only invited members can interact (react, comment, post).
217217+218218+Important: 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.
219219+220220+## AppView and Data Consumption
221221+222222+The 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.
223223+224224+### Jetstream
225225+226226+Jetstream 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.
227227+228228+- The AppView connects to a Jetstream instance and subscribes to events matching Exosphere's Lexicon record types.
229229+- Jetstream can be consumed from Bluesky's public instances, or self-hosted for full independence.
230230+- 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.
231231+232232+### Indexing pipeline
233233+234234+The 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`).
235235+236236+#### Deployment modes
237237+238238+| Mode | Command | Description |
239239+| ---------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------- |
240240+| **Combined** (default) | `bun run start` | API server starts the indexer in-process. Simplest deployment — one process, one start command. |
241241+| **API only** | `DISABLE_INDEXER=1 bun run start` | API server without the Jetstream consumer. Use when the indexer runs separately. |
242242+| **Indexer only** | `bun run start:indexer` | Standalone Jetstream consumer. Useful for scaling or isolating the indexer from the API. |
243243+244244+The 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.
245245+246246+#### Event flow
247247+248248+For Public Spheres with authenticated users, the AppView indexes data from the AT Protocol network:
249249+250250+```
251251+Jetstream (WebSocket, filtered by Exosphere Lexicons)
252252+ → Event received (e.g. a reaction record created on a user's PDS)
253253+ → Is this record referencing a known Sphere? → No → discard
254254+ → Is this Sphere open or is this user a member? → No → discard
255255+ → Index the record into SQLite
256256+ → Available via Sphere API
257257+```
258258+259259+For Private Spheres and unauthenticated data, there is no Jetstream consumption. Data is written directly to the Sphere database through the API:
260260+261261+```
262262+User → Sphere API (auth + membership check) → SQLite
263263+```
264264+265265+### Data flow summary
266266+267267+| Sphere type | Data written to | Data read from | Jetstream needed |
268268+| ----------- | --------------- | ---------------------- | ---------------- |
269269+| Private | Sphere SQLite | Sphere SQLite | No |
270270+| Public | User's PDS | AppView index (SQLite) | Yes |
271271+272272+## Membership Model
273273+274274+Membership 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.
275275+276276+### Bilateral membership records
277277+278278+Membership is represented by two complementary AT Protocol records:
279279+280280+1. **`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.
281281+2. **`site.exosphere.sphereMember`** — published on the **member's PDS** when they accept the invitation. Contains the Sphere AT URI.
282282+283283+Both 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.
284284+285285+### Local index (SQLite)
286286+287287+The `sphere_members` table caches membership state for fast access control:
288288+289289+- **DID**: The user's AT Protocol decentralized identifier
290290+- **Role**: Owner, admin, member (extensible per Sphere needs)
291291+- **Status**: Active, invited, revoked
292292+- **Invited by**: DID of the user who sent the invitation
293293+- **Joined at**: Timestamp
294294+295295+This 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.
296296+297297+### Invitation flow
298298+299299+1. A Sphere admin invites a user by their AT Protocol handle or DID.
300300+2. The handle is resolved to a DID (if needed).
301301+3. The admin publishes a `sphereMemberApproval` record on their PDS.
302302+4. An invitation record is created in the local membership table (status: invited).
303303+5. The invited user accepts via the Sphere UI.
304304+6. On acceptance, the user publishes a `sphereMember` record on their PDS.
305305+7. Local status is set to active and the user can participate according to the Sphere's write access mode.
306306+307307+### Private Spheres and membership privacy
308308+309309+For **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.
310310+311311+If 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.
312312+313313+### Enforcement points
314314+315315+| Layer | Role |
316316+| --------------------- | -------------------------------------------------------------------------------------------------------------- |
317317+| **AppView / Indexer** | Filters incoming Jetstream events. Only indexes interactions from active members (for members-only Spheres). |
318318+| **Sphere API** | Checks membership on write requests. Rejects unauthorized actions before they reach the database. |
319319+| **Client UI** | Hides write controls (comment box, reaction buttons) for non-members. UX convenience, not a security boundary. |
320320+321321+Membership 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.
+49
CLAUDE.md
···11+# Exosphere
22+33+Modular, self-hostable community platform built on the AT Protocol.
44+55+## Tools
66+77+- **Runtime & package manager**: Bun (workspaces monorepo)
88+- **Backend**: Hono.js on Bun, SQLite via `bun:sqlite`
99+- **Frontend**: Preact + Preact Signals, vanilla-extract for styling, Vite for dev/build
1010+- **Auth**: AT Protocol OAuth (`@atproto/*` packages)
1111+- **Validation**: Zod at system boundaries
1212+- **Language**: TypeScript (strict mode)
1313+- **Dev PDS**: Docker Compose (`docker-compose.dev.yml`) runs a local Bluesky PDS
1414+1515+### Commands
1616+1717+Check project TypeScript types with `bunx tsc --emit` or rely on the LSP plugin.
1818+1919+## Architecture
2020+2121+Monorepo with Bun workspaces under `packages/`:
2222+2323+| Package | Role |
2424+| ------------------ | --------------------------------------------------------------------------------------------------------- |
2525+| `core` | Shared infra: auth, db, sphere management, types |
2626+| `client` | Shared client-side utilities: API helpers, auth, router, theme, UI styles, reusable components |
2727+| `app` | Host app — assembles modules, Hono server entry (`src/server.ts`), Preact client entry (`src/client.tsx`) |
2828+| `feeds` | Feed & discussions module |
2929+| `feature-requests` | Feature requests module |
3030+3131+Each module exposes: a Hono sub-app (API routes), Preact components (UI), its own SQLite migrations, and a `ExosphereModule` interface. Modules depend on `core` but not on each other.
3232+3333+The `app` package registers modules in `src/server.ts` by mounting their API routes under `/api/<module-name>`.
3434+3535+See `ARCHITECTURE.md` for full details on data flow, AT Protocol integration, membership model, and access modes.
3636+3737+## TS/TSX coding style
3838+3939+- Strong typing and type safety
4040+- Avoid barrel files when possible
4141+- We are using Preact, in JSX, prefer `class` over `className`
4242+- Make optimistic updates when possible
4343+4444+## Styling
4545+4646+- **No universal `box-sizing: border-box`** — let elements use the default `content-box`. Only apply `border-box` on specific elements that need it (e.g. form controls with `width: 100%`).
4747+- **Prefer CSS logical properties** for margins, paddings, and borders (`margin-inline`, `padding-block`, `border-inline-start`, etc.) over physical properties (`margin-left`, `padding-top`, etc.).
4848+- **Prefer modern CSS** — use contemporary features and patterns over legacy approaches.
4949+- Avoid inline styling, prefer re-usable components from ./packages/client
+29
Dockerfile
···11+FROM oven/bun:1 AS base
22+33+WORKDIR /app
44+55+# Install dependencies
66+FROM base AS deps
77+COPY package.json bun.lock ./
88+COPY packages/core/package.json packages/core/package.json
99+COPY packages/client/package.json packages/client/package.json
1010+COPY packages/indexer/package.json packages/indexer/package.json
1111+COPY packages/feeds/package.json packages/feeds/package.json
1212+COPY packages/feature-requests/package.json packages/feature-requests/package.json
1313+COPY packages/mcp/package.json packages/mcp/package.json
1414+COPY packages/app/package.json packages/app/package.json
1515+RUN bun install --frozen-lockfile --ignore-scripts
1616+1717+FROM deps AS build
1818+COPY . .
1919+RUN bun run db:generate && bun run build
2020+2121+FROM base AS production
2222+WORKDIR /app
2323+COPY --from=build /app/packages ./packages
2424+COPY --from=build /app/drizzle ./drizzle
2525+COPY package.json bun.lock ./
2626+RUN bun install --frozen-lockfile --production
2727+ENV NODE_ENV=production
2828+EXPOSE 3001
2929+CMD ["sh", "-c", "chown -R bun:bun /data && exec su -s /bin/sh bun -c 'bun run db:migrate && bun run start'"]
+21
LICENSE
···11+MIT License
22+33+Copyright (c) 2026 Hugo (exosphere.site)
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+130
README.md
···11+# Context
22+33+Small groups of people and communities must rely on centralized and big tech tools to organize themselves or communicate.
44+55+There should be alternatives where communities can self host their platforms and own their data.
66+With full independence and no dependency on big tech.
77+88+Some tools exist (forums or small centralized platforms), but they often add friction.
99+1010+We believe that AT Protocol can solve many of these issues.
1111+1212+# Exosphere
1313+1414+Exosphere is a set of small and modular tools.
1515+1616+## Principle
1717+1818+- AT Protocol Authentication
1919+- Public spheres are publicly readable, all interactions require an AT Protocol account
2020+- Data belongs to the users (stored on their PDS)
2121+- Self hostable
2222+2323+## What kind of tools
2424+2525+- Feature requests
2626+- Kanban board
2727+- Feed and discussions
2828+- Micro blogging
2929+- Forums
3030+- Polls
3131+- Event organization
3232+- Forms
3333+3434+# Spheres
3535+3636+A Sphere is a community, a small group of friends, a family, a company, whatever.
3737+It can be self hosted, or rely on a Sphere hosting provider.
3838+3939+A Sphere can be public or private.
4040+4141+## Private Spheres
4242+4343+Private Spheres are invitation only, users must have an AT Protocol DID.
4444+All content is stored on the Sphere database to preserve privacy (AT Protocol repos are public by design).
4545+4646+## Public Spheres
4747+4848+Public Spheres content is readable by anyone. All interactions require AT Protocol authentication.
4949+5050+### Write access modes
5151+5252+- **Open** - Any authenticated user can participate (react, comment, post).
5353+- **Members only** - Public read, but only invited members can participate. Anyone can view the content, but interactions (reactions, comments) are restricted to approved members. Enforced at the AppView/indexer level.
5454+5555+### Future: unauthenticated access
5656+5757+Unauthenticated write access (e.g. anonymous poll votes) may be added later as an opt-in feature for specific modules. See [ARCHITECTURE.md](./ARCHITECTURE.md) for details on the tradeoffs.
5858+5959+# Technical implementation
6060+6161+We want the look and feel of Exosphere to be friendly but professional.
6262+Everything must be super reliable and fast from the ground up.
6363+6464+- Bun
6565+- AT Protocol
6666+- TypeScript
6767+ - Strong types everywhere
6868+ - Zod schemas where needed
6969+- Backend: Hono.js
7070+- Frontend: Preact
7171+ - Preact Signals for state management
7272+ - CSS / Design systems : vanilla-extract
7373+7474+# Getting started
7575+7676+## Prerequisites
7777+7878+- [Bun](https://bun.sh)
7979+- [Docker](https://www.docker.com/) (for the local PDS)
8080+8181+## Setup
8282+8383+```bash
8484+bun install
8585+8686+# Generate the OAuth private key (one-time)
8787+bun run packages/core/scripts/generate-key.ts
8888+```
8989+9090+Copy `.env.example` to `.env` and fill in the generated `OAUTH_PRIVATE_KEY`.
9191+9292+## Local PDS
9393+9494+A local PDS (Personal Data Server) lets you develop against AT Protocol without hitting production infrastructure.
9595+9696+```bash
9797+# Generate PDS config (one-time, creates pds/pds.env)
9898+bun run pds:init
9999+100100+# Start the PDS container
101101+bun run pds:up
102102+103103+# Create test accounts
104104+bun run pds:account alice
105105+bun run pds:account bob
106106+```
107107+108108+Each account is created with a handle like `alice.pds.dev` and a DID registered on `plc.directory`. The PDS runs at http://localhost:2583.
109109+110110+**Login with the DID**, not the handle. Local PDS handles (e.g. `alice.pds.dev`) don't resolve via DNS. The DID (`did:plc:...`) is printed when the account is created.
111111+112112+Other PDS commands:
113113+114114+| Command | Description |
115115+| ------------------ | ------------------------- |
116116+| `bun run pds:logs` | Follow PDS container logs |
117117+| `bun run pds:down` | Stop the PDS container |
118118+119119+## Run
120120+121121+```bash
122122+# Start both backend (port 3000) and frontend (port 5173)
123123+bun run dev
124124+125125+# Or start them separately
126126+bun run dev:server
127127+bun run dev:client
128128+```
129129+130130+Open http://localhost:5173 in your browser. The Vite dev server proxies `/api` requests to the backend.
+79
TODO.md
···11+# TODO
22+33+## Scaffolding
44+55+- [x] Monorepo setup (Bun workspaces)
66+- [x] Root TypeScript config
77+- [x] `@exosphere/core` package (db, auth placeholder, types)
88+- [x] `@exosphere/feeds` package (API routes, schemas, migrations, UI)
99+- [x] `@exosphere/app` package (Hono server + Vite dev setup)
1010+- [x] `ExosphereModule` interface for modular architecture
1111+- [x] SQLite setup with WAL mode
1212+- [x] Vite config with Preact and vanilla-extract plugins
1313+- [x] API proxy from Vite dev server to Hono backend
1414+1515+## Core
1616+1717+- [x] AT Protocol OAuth authentication (`core/auth`)
1818+- [x] Session middleware for Hono (protect routes, expose user DID)
1919+- [ ] Shared UI components (`core/ui` — layout, buttons, inputs)
2020+- [ ] Design tokens with vanilla-extract (colors, spacing, typography)
2121+- [x] Base migration system (run module migrations in order)
2222+2323+## Spheres
2424+2525+- [ ] Sphere creation and settings
2626+- [ ] Public vs private Spheres
2727+- [ ] Sphere-scoped feature requests
2828+- [ ] Sphere-scoped feeds
2929+- [ ] Cross-admin member revocation: when admin B revokes a member invited by admin A, the approval record on admin A's PDS can't be deleted. Consider publishing a revocation record on the revoker's own PDS (similar to `site.exosphere.moderation`) so third-party indexers see the revocation on-protocol.
3030+3131+## Feature Requests Module
3232+3333+- [x] Submit feature requests (title, description, category)
3434+- [ ] Handle feature request edition
3535+ - [ ] Should we have the ability to do it?
3636+ - [ ] How do we show to users that a feature request was indeed edited
3737+ - [ ] Should we reset vote count?
3838+- [x] Upvote requests
3939+- [x] Status workflow (open → approved/declined → done)
4040+ - [x] Admin ability to update status
4141+ - [ ] Post official/pinned responses
4242+- [x] Sort by top voted, newest
4343+- [ ] Filter by status by category
4444+- [ ] Comments on requests
4545+ - [x] Simple comments
4646+ - [ ] Comments threads on requests
4747+- [x] Merge duplicate requests
4848+- [ ] Request categories management
4949+5050+## Feeds Module
5151+5252+- [ ] Full CRUD for posts (create, read, update, delete)
5353+- [ ] Write post records to user PDS
5454+- [ ] Index PDS records in local SQLite
5555+- [ ] Reply threads (nested replies, thread view)
5656+- [ ] Feed pagination
5757+- [ ] Post composer UI component
5858+- [ ] Thread view UI component
5959+6060+## Lexicons
6161+6262+- [x] Define formal Lexicon schemas for all AT Protocol record types (exact record shapes are still being defined)
6363+ - [x] `site.exosphere.featureRequest`
6464+ - [ ] Feed/post record types
6565+- [ ] Set up Lexicon codegen for type-safe record handling
6666+6767+## Infrastructure
6868+6969+- [ ] Docker
7070+- [x] Vite production build (serve static assets from Hono)
7171+- [x] Environment config (.env handling)
7272+7373+## Future Modules
7474+7575+- [ ] Kanban board
7676+- [ ] Forums
7777+- [ ] Polls
7878+- [ ] Event organization
7979+- [ ] Forms
···11+CREATE TABLE `oauth_sessions` (
22+ `key` text PRIMARY KEY NOT NULL,
33+ `session` text NOT NULL,
44+ `created_at` text DEFAULT (datetime('now')) NOT NULL,
55+ `updated_at` text DEFAULT (datetime('now')) NOT NULL
66+);
77+--> statement-breakpoint
88+CREATE TABLE `oauth_states` (
99+ `key` text PRIMARY KEY NOT NULL,
1010+ `state` text NOT NULL,
1111+ `created_at` text DEFAULT (datetime('now')) NOT NULL
1212+);
1313+--> statement-breakpoint
1414+CREATE TABLE `indexer_cursor` (
1515+ `id` text PRIMARY KEY DEFAULT 'jetstream' NOT NULL,
1616+ `cursor` integer NOT NULL,
1717+ `updated_at` text DEFAULT (datetime('now')) NOT NULL
1818+);
1919+--> statement-breakpoint
2020+CREATE TABLE `sphere_members` (
2121+ `sphere_id` text NOT NULL,
2222+ `did` text NOT NULL,
2323+ `role` text DEFAULT 'member' NOT NULL,
2424+ `status` text DEFAULT 'invited' NOT NULL,
2525+ `invited_by` text,
2626+ `pds_uri` text,
2727+ `approval_pds_uri` text,
2828+ `created_at` text DEFAULT (datetime('now')) NOT NULL,
2929+ PRIMARY KEY(`sphere_id`, `did`),
3030+ FOREIGN KEY (`sphere_id`) REFERENCES `spheres`(`id`) ON UPDATE no action ON DELETE no action
3131+);
3232+--> statement-breakpoint
3333+CREATE INDEX `idx_sphere_members_did` ON `sphere_members` (`did`);--> statement-breakpoint
3434+CREATE TABLE `sphere_modules` (
3535+ `sphere_id` text NOT NULL,
3636+ `module_name` text NOT NULL,
3737+ `enabled_at` text DEFAULT (datetime('now')) NOT NULL,
3838+ PRIMARY KEY(`sphere_id`, `module_name`),
3939+ FOREIGN KEY (`sphere_id`) REFERENCES `spheres`(`id`) ON UPDATE no action ON DELETE no action
4040+);
4141+--> statement-breakpoint
4242+CREATE TABLE `spheres` (
4343+ `id` text PRIMARY KEY NOT NULL,
4444+ `slug` text NOT NULL,
4545+ `name` text NOT NULL,
4646+ `description` text,
4747+ `visibility` text DEFAULT 'public' NOT NULL,
4848+ `write_access` text DEFAULT 'open' NOT NULL,
4949+ `owner_did` text NOT NULL,
5050+ `pds_uri` text,
5151+ `created_at` text DEFAULT (datetime('now')) NOT NULL,
5252+ `updated_at` text DEFAULT (datetime('now')) NOT NULL
5353+);
5454+--> statement-breakpoint
5555+CREATE UNIQUE INDEX `spheres_slug_unique` ON `spheres` (`slug`);--> statement-breakpoint
5656+CREATE TABLE `feature_request_comment_votes` (
5757+ `comment_id` text NOT NULL,
5858+ `author_did` text NOT NULL,
5959+ `pds_uri` text,
6060+ `created_at` text DEFAULT (datetime('now')) NOT NULL,
6161+ PRIMARY KEY(`comment_id`, `author_did`),
6262+ FOREIGN KEY (`comment_id`) REFERENCES `feature_request_comments`(`id`) ON UPDATE no action ON DELETE no action
6363+);
6464+--> statement-breakpoint
6565+CREATE INDEX `idx_feature_request_comment_votes_comment` ON `feature_request_comment_votes` (`comment_id`);--> statement-breakpoint
6666+CREATE TABLE `feature_request_comments` (
6767+ `id` text PRIMARY KEY NOT NULL,
6868+ `request_id` text NOT NULL,
6969+ `author_did` text NOT NULL,
7070+ `content` text NOT NULL,
7171+ `pds_uri` text,
7272+ `created_at` text DEFAULT (datetime('now')) NOT NULL,
7373+ `updated_at` text DEFAULT (datetime('now')) NOT NULL,
7474+ `hidden_at` text,
7575+ `moderated_by` text,
7676+ FOREIGN KEY (`request_id`) REFERENCES `feature_requests`(`id`) ON UPDATE no action ON DELETE no action
7777+);
7878+--> statement-breakpoint
7979+CREATE INDEX `idx_feature_request_comments_request` ON `feature_request_comments` (`request_id`);--> statement-breakpoint
8080+CREATE INDEX `idx_feature_request_comments_author_request` ON `feature_request_comments` (`author_did`,`request_id`);--> statement-breakpoint
8181+CREATE TABLE `feature_request_statuses` (
8282+ `id` text PRIMARY KEY NOT NULL,
8383+ `request_id` text NOT NULL,
8484+ `author_did` text NOT NULL,
8585+ `status` text NOT NULL,
8686+ `pds_uri` text,
8787+ `created_at` text DEFAULT (datetime('now')) NOT NULL,
8888+ FOREIGN KEY (`request_id`) REFERENCES `feature_requests`(`id`) ON UPDATE no action ON DELETE no action
8989+);
9090+--> statement-breakpoint
9191+CREATE INDEX `idx_feature_request_statuses_request` ON `feature_request_statuses` (`request_id`);--> statement-breakpoint
9292+CREATE TABLE `feature_request_votes` (
9393+ `request_id` text NOT NULL,
9494+ `author_did` text NOT NULL,
9595+ `pds_uri` text,
9696+ `created_at` text DEFAULT (datetime('now')) NOT NULL,
9797+ PRIMARY KEY(`request_id`, `author_did`),
9898+ FOREIGN KEY (`request_id`) REFERENCES `feature_requests`(`id`) ON UPDATE no action ON DELETE no action
9999+);
100100+--> statement-breakpoint
101101+CREATE INDEX `idx_feature_request_votes_request` ON `feature_request_votes` (`request_id`);--> statement-breakpoint
102102+CREATE TABLE `feature_requests` (
103103+ `id` text PRIMARY KEY NOT NULL,
104104+ `number` integer NOT NULL,
105105+ `author_did` text NOT NULL,
106106+ `title` text NOT NULL,
107107+ `description` text NOT NULL,
108108+ `category` text DEFAULT 'general' NOT NULL,
109109+ `status` text DEFAULT 'requested' NOT NULL,
110110+ `duplicate_of_id` text,
111111+ `pds_uri` text,
112112+ `hidden_at` text,
113113+ `moderated_by` text,
114114+ `created_at` text DEFAULT (datetime('now')) NOT NULL,
115115+ `updated_at` text DEFAULT (datetime('now')) NOT NULL
116116+);
117117+--> statement-breakpoint
118118+CREATE UNIQUE INDEX `feature_requests_number_unique` ON `feature_requests` (`number`);--> statement-breakpoint
119119+CREATE INDEX `idx_feature_requests_status` ON `feature_requests` (`status`);--> statement-breakpoint
120120+CREATE INDEX `idx_feature_requests_created` ON `feature_requests` (`created_at`);--> statement-breakpoint
121121+CREATE INDEX `idx_feature_requests_category` ON `feature_requests` (`category`);--> statement-breakpoint
122122+CREATE TABLE `feed_posts` (
123123+ `id` text PRIMARY KEY NOT NULL,
124124+ `author_did` text NOT NULL,
125125+ `content` text NOT NULL,
126126+ `parent_id` text,
127127+ `pds_uri` text,
128128+ `created_at` text DEFAULT (datetime('now')) NOT NULL,
129129+ `updated_at` text DEFAULT (datetime('now')) NOT NULL
130130+);
131131+--> statement-breakpoint
132132+CREATE INDEX `idx_feed_posts_parent` ON `feed_posts` (`parent_id`);--> statement-breakpoint
133133+CREATE INDEX `idx_feed_posts_created` ON `feed_posts` (`created_at`);
···11+export { useLocation, useRoute } from "preact-iso";
22+// Re-exported from preact-iso/lazy (not preact/compat) because it installs
33+// the options.__e patch that preact-iso's Router needs to handle suspense.
44+export { default as lazy } from "preact-iso/lazy";
···11+{
22+ "lexicon": 1,
33+ "id": "site.exosphere.featureRequestStatus",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "description": "A status change on a feature request, published by an admin/owner on their PDS. Third-party indexers can replay these to derive the current status of a request.",
88+ "key": "tid",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["subject", "status", "sphereSlug", "createdAt"],
1212+ "properties": {
1313+ "subject": {
1414+ "type": "string",
1515+ "format": "at-uri",
1616+ "description": "AT URI of the feature request whose status is being changed."
1717+ },
1818+ "status": {
1919+ "type": "string",
2020+ "knownValues": ["requested", "not-planned", "approved", "in-progress", "done"]
2121+ },
2222+ "sphereSlug": {
2323+ "type": "string",
2424+ "description": "Slug of the Sphere (used by indexers to scope the status change)."
2525+ },
2626+ "createdAt": {
2727+ "type": "string",
2828+ "format": "datetime"
2929+ }
3030+ }
3131+ }
3232+ }
3333+ }
3434+}
···11+{
22+ "lexicon": 1,
33+ "id": "site.exosphere.sphereMember",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "description": "A user's declaration that they are a member of a Sphere. Published on the member's own PDS. The Sphere owner/admin publishes a corresponding sphereMemberApproval record to confirm the membership. Together, the two records form a bilateral proof of membership — similar to how AT Protocol follows work.",
88+ "key": "tid",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["sphere", "createdAt"],
1212+ "properties": {
1313+ "sphere": {
1414+ "type": "string",
1515+ "format": "at-uri",
1616+ "description": "AT URI of the Sphere record (site.exosphere.sphere) on the owner's PDS."
1717+ },
1818+ "createdAt": {
1919+ "type": "string",
2020+ "format": "datetime"
2121+ }
2222+ }
2323+ }
2424+ }
2525+ }
2626+}
···11+export { statuses, settableStatuses, statusLabels } from "../../types.ts";
22+export type { Status, SettableStatus } from "../../types.ts";
33+44+export * from "./feature-requests.ts";
55+export * from "./votes.ts";
66+export * from "./comments.ts";
77+export * from "./comment-votes.ts";
88+export * from "./status.ts";
99+export * from "./search.ts";
···11+/**
22+ * Create a test account on the local PDS.
33+ *
44+ * The PDS must be running (docker compose -f docker-compose.dev.yml up -d).
55+ *
66+ * Usage:
77+ * bun run scripts/pds-account.ts <name> [password]
88+ *
99+ * Examples:
1010+ * bun run scripts/pds-account.ts alice
1111+ * bun run scripts/pds-account.ts bob secret123
1212+ *
1313+ * The handle is derived from PDS_HOSTNAME in pds/pds.env (e.g. alice.pds.dev).
1414+ * Outputs the created DID which you can use to log in during local development.
1515+ */
1616+1717+import { readFileSync } from "node:fs";
1818+import { join } from "node:path";
1919+2020+const PDS_URL = process.env.PDS_URL ?? "http://localhost:3000";
2121+const ENV_FILE = join(import.meta.dir, "..", "pds", "pds.env");
2222+2323+function readEnvVar(name: string): string {
2424+ const env = readFileSync(ENV_FILE, "utf-8");
2525+ const match = env.match(new RegExp(`^${name}=(.+)$`, "m"));
2626+ if (!match) throw new Error(`${name} not found in pds/pds.env`);
2727+ return match[1].trim();
2828+}
2929+3030+const name = process.argv[2];
3131+const password = process.argv[3] ?? "localdev";
3232+3333+if (!name) {
3434+ console.error("Usage: bun run scripts/pds-account.ts <name> [password]");
3535+ console.error("Example: bun run scripts/pds-account.ts alice");
3636+ process.exit(1);
3737+}
3838+3939+const pdsHostname = readEnvVar("PDS_HOSTNAME");
4040+const handle = `${name}.${pdsHostname}`;
4141+4242+const adminPassword = readEnvVar("PDS_ADMIN_PASSWORD");
4343+4444+// Use the PDS admin API to create an invite code first
4545+const inviteRes = await fetch(`${PDS_URL}/xrpc/com.atproto.server.createInviteCode`, {
4646+ method: "POST",
4747+ headers: {
4848+ "Content-Type": "application/json",
4949+ Authorization: `Basic ${btoa(`admin:${adminPassword}`)}`,
5050+ },
5151+ body: JSON.stringify({ useCount: 1 }),
5252+});
5353+5454+if (!inviteRes.ok) {
5555+ const text = await inviteRes.text();
5656+ console.error(`Failed to create invite code: ${inviteRes.status} ${text}`);
5757+ process.exit(1);
5858+}
5959+6060+const { code: inviteCode } = (await inviteRes.json()) as { code: string };
6161+6262+// Create the account
6363+const email = `${handle.replace(/\./g, "-")}@example.com`;
6464+6565+const createRes = await fetch(`${PDS_URL}/xrpc/com.atproto.server.createAccount`, {
6666+ method: "POST",
6767+ headers: { "Content-Type": "application/json" },
6868+ body: JSON.stringify({
6969+ handle,
7070+ email,
7171+ password,
7272+ inviteCode,
7373+ }),
7474+});
7575+7676+if (!createRes.ok) {
7777+ const text = await createRes.text();
7878+ console.error(`Failed to create account: ${createRes.status} ${text}`);
7979+ process.exit(1);
8080+}
8181+8282+const account = (await createRes.json()) as {
8383+ did: string;
8484+ handle: string;
8585+ accessJwt: string;
8686+};
8787+8888+console.log("Account created successfully!");
8989+console.log(` Handle: ${account.handle}`);
9090+console.log(` DID: ${account.did}`);
9191+console.log(` Email: ${email}`);
9292+console.log(` Password: ${password}`);
9393+console.log("");
9494+console.log("Use the DID to log in via Exosphere (handle won't resolve via DNS on local PDS).");
+62
scripts/pds-init.ts
···11+/**
22+ * Initialize the local PDS environment for development.
33+ *
44+ * Generates the pds/pds.env file with the necessary secrets and configuration.
55+ * Run this once before starting the PDS for the first time.
66+ *
77+ * Usage: bun run scripts/pds-init.ts
88+ */
99+1010+import { existsSync, mkdirSync, writeFileSync } from "node:fs";
1111+import { join } from "node:path";
1212+import { randomBytes } from "node:crypto";
1313+1414+const ROOT = join(import.meta.dir, "..");
1515+const PDS_DIR = join(ROOT, "pds");
1616+const ENV_FILE = join(PDS_DIR, "pds.env");
1717+1818+if (existsSync(ENV_FILE)) {
1919+ console.log("pds/pds.env already exists. Delete it to regenerate.");
2020+ process.exit(0);
2121+}
2222+2323+mkdirSync(PDS_DIR, { recursive: true });
2424+2525+const jwtSecret = randomBytes(32).toString("hex");
2626+const adminPassword = "localdev";
2727+const plcRotationKey = randomBytes(32).toString("hex");
2828+2929+// AT Protocol disallows these TLDs for handles:
3030+// .local, .arpa, .invalid, .localhost, .internal, .example, .alt, .onion
3131+// We use a real-looking domain so handle validation passes.
3232+// Handles will be <name>.pds.dev — they won't resolve via DNS, but that's
3333+// fine for local dev (use the DID to log in instead).
3434+const pdsHostname = "pds.dev";
3535+3636+const env = `# Generated by scripts/pds-init.ts — do not commit
3737+PDS_HOSTNAME=${pdsHostname}
3838+PDS_PORT=3000
3939+PDS_JWT_SECRET=${jwtSecret}
4040+PDS_ADMIN_PASSWORD=${adminPassword}
4141+PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=${plcRotationKey}
4242+PDS_DATA_DIRECTORY=/pds/data
4343+PDS_BLOBSTORE_DISK_LOCATION=/pds/data/blocks
4444+PDS_DID_PLC_URL=https://plc.directory
4545+PDS_DEV_MODE=1
4646+PDS_LOG_ENABLED=true
4747+4848+# AppView and moderation are not needed for local dev
4949+# but the PDS may require them to start
5050+PDS_BSKY_APP_VIEW_URL=https://api.bsky.app
5151+PDS_BSKY_APP_VIEW_DID=did:web:api.bsky.app
5252+PDS_REPORT_SERVICE_URL=https://mod.bsky.app
5353+PDS_REPORT_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac
5454+PDS_CRAWLERS=
5555+`;
5656+5757+writeFileSync(ENV_FILE, env);
5858+console.log("Created pds/pds.env");
5959+console.log(` Admin password: ${adminPassword}`);
6060+console.log("");
6161+console.log("Start the PDS with:");
6262+console.log(" docker compose -f docker-compose.dev.yml up -d");