···88# and URL rewriting from the PDS hostname to localhost.
99PDS_URL=http://localhost:3000
10101111+# Enable multi-sphere (hosted) mode: path-based routing under /s/:slug
1212+# Leave unset or set to 0 for single-sphere self-hosted mode.
1313+# MULTI_SPHERE=1
1414+1115# ES256 private key in JWK format for OAuth client authentication
1216# Only needed in production (local dev uses loopback auth with no key).
1317# Generate one with: bun run packages/core/scripts/generate-key.ts
···101101CREATE INDEX `idx_feature_request_votes_request` ON `feature_request_votes` (`request_id`);--> statement-breakpoint
102102CREATE TABLE `feature_requests` (
103103 `id` text PRIMARY KEY NOT NULL,
104104+ `sphere_id` text NOT NULL,
104105 `number` integer NOT NULL,
105106 `author_did` text NOT NULL,
106107 `title` text NOT NULL,
···115116 `updated_at` text DEFAULT (datetime('now')) NOT NULL
116117);
117118--> statement-breakpoint
118118-CREATE UNIQUE INDEX `feature_requests_number_unique` ON `feature_requests` (`number`);--> statement-breakpoint
119119+CREATE UNIQUE INDEX `idx_feature_requests_sphere_number` ON `feature_requests` (`sphere_id`,`number`);--> statement-breakpoint
120120+CREATE INDEX `idx_feature_requests_sphere` ON `feature_requests` (`sphere_id`);--> statement-breakpoint
119121CREATE INDEX `idx_feature_requests_status` ON `feature_requests` (`status`);--> statement-breakpoint
120122CREATE INDEX `idx_feature_requests_created` ON `feature_requests` (`created_at`);--> statement-breakpoint
121123CREATE INDEX `idx_feature_requests_category` ON `feature_requests` (`category`);--> statement-breakpoint
···11+import { isMultiSphere } from "./config.ts";
22+import { sphereSlug } from "./sphere.ts";
33+14export class ApiError extends Error {
25 constructor(
36 public status: number,
···1821 }
1922 return data as T;
2023}
2424+2525+/**
2626+ * Fetch a module API endpoint with automatic sphere-scoping.
2727+ * `path` should start with the module name, e.g. "/feature-requests/123".
2828+ *
2929+ * - Multi-sphere mode: calls `/api/s/{sphereSlug}/{path}`
3030+ * - Self-hosted mode: calls `/api/{path}`
3131+ */
3232+export function moduleFetch<T>(path: string, options?: RequestInit): Promise<T> {
3333+ const slug = sphereSlug.value;
3434+ const base = isMultiSphere && slug ? `/api/s/${slug}` : "/api";
3535+ return apiFetch<T>(`${base}${path}`, options);
3636+}
+11
packages/client/src/config.ts
···11+/** Whether the app runs in multi-sphere (hosted) mode.
22+ * Initialized at build time via Vite's `define` (works even without SSR).
33+ * Can be overridden per-request during SSR via `setMultiSphere()`. */
44+declare const __MULTI_SPHERE__: boolean;
55+export let isMultiSphere: boolean =
66+ typeof __MULTI_SPHERE__ !== "undefined" ? __MULTI_SPHERE__ : false;
77+88+/** Called once at bootstrap with the value from __SSR_DATA__ or server config. */
99+export function setMultiSphere(value: boolean) {
1010+ isMultiSphere = value;
1111+}
+13
packages/client/src/router.tsx
···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.
44export { default as lazy } from "preact-iso/lazy";
55+66+import { isMultiSphere } from "./config.ts";
77+import { sphereSlug } from "./sphere.ts";
88+99+/** Build a path with the sphere prefix in multi-sphere mode.
1010+ * e.g. spherePath("/infuse") → "/s/my-team/infuse" (hosted) or "/infuse" (self-hosted) */
1111+export function spherePath(path: string): string {
1212+ if (!isMultiSphere) return path;
1313+ const slug = sphereSlug.value;
1414+ if (!slug) return path;
1515+ // Avoid trailing slash for root path: spherePath("/") → "/s/slug" not "/s/slug/"
1616+ return path === "/" ? `/s/${slug}` : `/s/${slug}${path}`;
1717+}
+24-3
packages/client/src/sphere.ts
···11import { signal, computed } from "@preact/signals";
22import type { SphereData } from "@exosphere/core/types";
33import { apiFetch } from "./api.ts";
44+import { isMultiSphere } from "./config.ts";
4556const LOADING_DELAY = 400;
67···20212122export const sphereSlug = computed(() => sphereState.value.data?.sphere.slug ?? null);
22232323-export async function loadSphere() {
2424+/**
2525+ * Load a sphere. In multi-sphere mode, pass the slug from the URL.
2626+ * In self-hosted mode (no slug), loads `/api/spheres/current`.
2727+ */
2828+export async function loadSphere(slug?: string) {
2929+ // In multi-sphere mode with no slug, there's no sphere to load (dashboard page)
3030+ if (isMultiSphere && !slug) {
3131+ sphereState.value = { pending: false, loading: false, data: null, error: null };
3232+ return;
3333+ }
2434 sphereState.value = { pending: true, loading: false, data: null, error: null };
2535 const timer = setTimeout(() => {
2636 sphereState.value = { ...sphereState.value, loading: true };
2737 }, LOADING_DELAY);
2838 try {
2929- const data = await apiFetch<SphereData>("/api/spheres/current");
3939+ const url = slug ? `/api/spheres/${slug}` : "/api/spheres/current";
4040+ const data = await apiFetch<SphereData>(url);
3041 clearTimeout(timer);
3142 sphereState.value = { pending: false, loading: false, data, error: null };
3243 } catch (err) {
···42534354/** Silent refresh — keeps existing data visible while fetching. */
4455export async function refreshSphere() {
5656+ const slug = sphereSlug.value;
5757+ // In multi-sphere mode with no current sphere, nothing to refresh
5858+ if (isMultiSphere && !slug) return;
4559 try {
4646- const data = await apiFetch<SphereData>("/api/spheres/current");
6060+ const url = slug ? `/api/spheres/${slug}` : "/api/spheres/current";
6161+ const data = await apiFetch<SphereData>(url);
4762 sphereState.value = { pending: false, loading: false, data, error: null };
4863 } catch (err) {
4964 sphereState.value = {
···5267 };
5368 }
5469}
7070+7171+/** Extract sphere slug from the current URL path in multi-sphere mode. */
7272+export function getSphereSlugFromUrl(): string | null {
7373+ const match = location.pathname.match(/^\/s\/([^/]+)/);
7474+ return match?.[1] ?? null;
7575+}
···11111212const SPHERE_COLLECTION = "site.exosphere.sphere";
13131414-/** Load the current sphere with its modules, member count, and caller's role. */
1515-export function getCurrentSphere(did: string | null) {
1616- const sphere = getDb().select().from(spheres).orderBy(spheres.createdAt).limit(1).get();
1414+/** Load the current sphere with its modules, member count, and caller's role.
1515+ * If `slug` is provided, loads that specific sphere; otherwise loads the first sphere. */
1616+export function getCurrentSphere(did: string | null, slug?: string) {
1717+ const sphere = slug
1818+ ? getDb().select().from(spheres).where(eq(spheres.slug, slug)).get()
1919+ : getDb().select().from(spheres).orderBy(spheres.createdAt).limit(1).get();
1720 if (!sphere) return null;
1821 const modules = getEnabledModules(sphere.id);
1922 const memberCount = getDb()
···9194 return c.json({ sphere }, 201);
9295});
93969494-// List spheres
9595-app.get("/", (c) => {
9696- const rows = getDb().select().from(spheres).orderBy(spheres.createdAt).all();
9797+// List spheres. ?member=true filters to spheres the caller is a member of.
9898+app.get("/", optionalAuth, (c) => {
9999+ const db = getDb();
100100+ const memberOnly = c.req.query("member") === "true";
101101+ const did = c.var.did;
102102+103103+ if (memberOnly && !did) {
104104+ return c.json({ spheres: [] });
105105+ }
106106+107107+ if (memberOnly && did) {
108108+ const rows = db
109109+ .select({ sphere: spheres })
110110+ .from(spheres)
111111+ .innerJoin(
112112+ sphereMembers,
113113+ and(
114114+ eq(sphereMembers.sphereId, spheres.id),
115115+ eq(sphereMembers.did, did),
116116+ eq(sphereMembers.status, "active"),
117117+ ),
118118+ )
119119+ .orderBy(spheres.createdAt)
120120+ .all();
121121+ return c.json({ spheres: rows.map((r) => r.sphere) });
122122+ }
123123+124124+ const rows = db.select().from(spheres).orderBy(spheres.createdAt).all();
97125 return c.json({ spheres: rows });
98126});
99127
+1
packages/core/src/sphere/index.ts
···77 findSphereByAtUri,
88} from "./operations.ts";
99export type { ModerationHandler } from "./operations.ts";
1010+export { sphereContext } from "./middleware.ts";
+43
packages/core/src/sphere/middleware.ts
···11+import { createMiddleware } from "hono/factory";
22+import { eq } from "../db/drizzle.ts";
33+import { getDb } from "../db/index.ts";
44+import { spheres } from "../db/schema/index.ts";
55+import { isMultiSphere } from "../config.ts";
66+import type { SphereEnv } from "../types/index.ts";
77+88+/**
99+ * Middleware that resolves the current sphere and injects it into the Hono context.
1010+ *
1111+ * - Multi-sphere mode: reads `:sphereSlug` from URL params.
1212+ * - Self-hosted mode: loads the first (and only) sphere.
1313+ *
1414+ * Sets `c.var.sphereId`, `c.var.sphereSlug`, `c.var.sphereVisibility`,
1515+ * `c.var.sphereWriteAccess`, `c.var.sphereOwnerDid`, `c.var.spherePdsUri`.
1616+ */
1717+export const sphereContext = createMiddleware<SphereEnv>(async (c, next) => {
1818+ const db = getDb();
1919+ let sphere;
2020+2121+ if (isMultiSphere) {
2222+ const slug = c.req.param("sphereSlug");
2323+ if (!slug) {
2424+ return c.json({ error: "Sphere not found" }, 404);
2525+ }
2626+ sphere = db.select().from(spheres).where(eq(spheres.slug, slug)).get();
2727+ } else {
2828+ sphere = db.select().from(spheres).orderBy(spheres.createdAt).limit(1).get();
2929+ }
3030+3131+ if (!sphere) {
3232+ return c.json({ error: "Sphere not found" }, 404);
3333+ }
3434+3535+ c.set("sphereId", sphere.id);
3636+ c.set("sphereSlug", sphere.slug);
3737+ c.set("sphereVisibility", sphere.visibility as "public" | "private");
3838+ c.set("sphereWriteAccess", sphere.writeAccess as "open" | "members");
3939+ c.set("sphereOwnerDid", sphere.ownerDid);
4040+ c.set("spherePdsUri", sphere.pdsUri);
4141+4242+ await next();
4343+});
+12
packages/core/src/types/index.ts
···22import type { BlankSchema } from "hono/types";
33import type { SphereMember } from "../db/schema/spheres.ts";
4455+/** Hono env variables set by the sphereContext middleware. */
66+export type SphereEnv = {
77+ Variables: {
88+ sphereId: string;
99+ sphereSlug: string;
1010+ sphereVisibility: "public" | "private";
1111+ sphereWriteAccess: "open" | "members";
1212+ sphereOwnerDid: string;
1313+ spherePdsUri: string | null;
1414+ };
1515+};
1616+517export interface JetstreamCommitEvent {
618 did: string;
719 time_us: number;
···11+# Multi-Sphere Code Review
22+33+## Warnings
44+55+### `api.ts` / `sphere.ts` — circular import
66+77+- **File**: `packages/client/src/api.ts:2` and `packages/client/src/sphere.ts:3`
88+- **Issue**: `api.ts` imports `sphereSlug` from `sphere.ts`, while `sphere.ts` imports `apiFetch` from `api.ts`. This works with ESM live bindings since neither import is used at module-initialization time, but it's fragile — any future top-level usage in either file will break.
99+- **Fix**: Move `moduleFetch` to its own file (e.g. `module-api.ts`) that imports from both `api.ts` and `sphere.ts`, breaking the cycle.
1010+1111+### `SphereLoader` reads signal during render, causing unnecessary re-renders
1212+1313+- **File**: `packages/app/src/app.tsx:73`
1414+- **Issue**: `const currentSlug = sphereSlug.value` subscribes the component to `sphereState` changes. Since `SphereLoader` returns `null`, these re-renders are wasted work.
1515+- **Fix**: Read the signal inside the effect:
1616+```tsx
1717+function SphereLoader() {
1818+ const { params } = useRoute();
1919+ useEffect(() => {
2020+ const urlSlug = params.sphereSlug;
2121+ if (urlSlug && urlSlug !== sphereSlug.peek()) {
2222+ loadSphere(urlSlug);
2323+ }
2424+ }, [params.sphereSlug]);
2525+ return null;
2626+}
2727+```
2828+2929+### `sphereId` has no FK constraint
3030+3131+- **File**: `packages/feature-requests/src/db/schema.ts:15`
3232+- **Issue**: `sphereId: text("sphere_id").notNull()` doesn't reference the `spheres` table. If a sphere is deleted, orphaned feature requests would remain with no referential integrity enforcement.
3333+- **Fix**: Add a foreign key reference (both tables are in the same SQLite database):
3434+```ts
3535+import { spheres } from "@exosphere/core/db/schema";
3636+// ...
3737+sphereId: text("sphere_id").notNull().references(() => spheres.id),
3838+```
3939+4040+### `DELETE /:id/vote` not sphere-scoped
4141+4242+- **File**: `packages/feature-requests/src/api/votes.ts`
4343+- **Issue**: The unvote handler (and similarly `DELETE /comments/:id/vote`) doesn't verify the vote's feature request belongs to the current sphere. A user could call `DELETE /api/s/sphere-a/feature-requests/{id}/vote` to remove a vote on a feature request that belongs to `sphere-b`. Not exploitable (the result is the same — the vote is removed), but inconsistent with the other endpoints that all verify sphere membership.
4444+- **Fix**: Add `eq(featureRequests.sphereId, sphereId)` to the vote lookup query in the DELETE handler, same as the POST handler.
4545+4646+## Suggestions
4747+4848+### Mutable module-level state shared across SSR requests
4949+5050+- **File**: `packages/client/src/config.ts`
5151+- **Issue**: `export let isMultiSphere` is module-level mutable state. In SSR, `setMultiSphere` is called before each render, and since Bun is single-threaded with synchronous prerender, this is safe today. But if SSR ever becomes concurrent, this becomes a race. Worth a `// NOTE:` comment documenting this assumption.
5252+5353+### Empty render when `pending && !loading`
5454+5555+- **File**: `packages/app/src/pages/dashboard.tsx:32`
5656+- **Issue**: When `pending` is true but `loading` is false (the brief initial state before the loading delay timer fires), the sphere list area renders `null` — the page shows the title and button but no content area at all. For SSR, this means the server-rendered HTML contains only the heading.
5757+- **Fix**: Consider rendering a minimal placeholder or skipping the loading delay for SSR.
5858+5959+## Summary
6060+6161+Overall the changes are clean and well-structured. The sphere context middleware centralizes what was previously scattered sphere lookups across handlers. The `moduleFetch` abstraction is a nice simplification for the client API layer. The migration approach (regenerating the initial migration) is fine for a pre-launch project.