···4141--> statement-breakpoint
4242CREATE TABLE `spheres` (
4343 `id` text PRIMARY KEY NOT NULL,
4444- `slug` text NOT NULL,
4444+ `handle` text NOT NULL,
4545 `name` text NOT NULL,
4646 `description` text,
4747 `visibility` text DEFAULT 'public' 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
5555+CREATE UNIQUE INDEX `spheres_handle_unique` ON `spheres` (`handle`);--> statement-breakpoint
5656CREATE TABLE `feature_request_comment_votes` (
5757 `comment_id` text NOT NULL,
5858 `author_did` text NOT NULL,
···113113 `hidden_at` text,
114114 `moderated_by` text,
115115 `created_at` text DEFAULT (datetime('now')) NOT NULL,
116116- `updated_at` text DEFAULT (datetime('now')) NOT NULL
116116+ `updated_at` text DEFAULT (datetime('now')) NOT NULL,
117117+ FOREIGN KEY (`sphere_id`) REFERENCES `spheres`(`id`) ON UPDATE no action ON DELETE no action
117118);
118119--> statement-breakpoint
119120CREATE UNIQUE INDEX `idx_feature_requests_sphere_number` ON `feature_requests` (`sphere_id`,`number`);--> statement-breakpoint
+28
drizzle/0001_blue_trauma.sql
···11+PRAGMA foreign_keys=OFF;--> statement-breakpoint
22+CREATE TABLE `__new_feature_requests` (
33+ `id` text PRIMARY KEY NOT NULL,
44+ `sphere_id` text NOT NULL,
55+ `number` integer NOT NULL,
66+ `author_did` text NOT NULL,
77+ `title` text NOT NULL,
88+ `description` text NOT NULL,
99+ `category` text DEFAULT 'general' NOT NULL,
1010+ `status` text DEFAULT 'requested' NOT NULL,
1111+ `duplicate_of_id` text,
1212+ `pds_uri` text,
1313+ `hidden_at` text,
1414+ `moderated_by` text,
1515+ `created_at` text DEFAULT (datetime('now')) NOT NULL,
1616+ `updated_at` text DEFAULT (datetime('now')) NOT NULL,
1717+ FOREIGN KEY (`sphere_id`) REFERENCES `spheres`(`id`) ON UPDATE no action ON DELETE no action
1818+);
1919+--> statement-breakpoint
2020+INSERT INTO `__new_feature_requests`("id", "sphere_id", "number", "author_did", "title", "description", "category", "status", "duplicate_of_id", "pds_uri", "hidden_at", "moderated_by", "created_at", "updated_at") SELECT "id", "sphere_id", "number", "author_did", "title", "description", "category", "status", "duplicate_of_id", "pds_uri", "hidden_at", "moderated_by", "created_at", "updated_at" FROM `feature_requests`;--> statement-breakpoint
2121+DROP TABLE `feature_requests`;--> statement-breakpoint
2222+ALTER TABLE `__new_feature_requests` RENAME TO `feature_requests`;--> statement-breakpoint
2323+PRAGMA foreign_keys=ON;--> statement-breakpoint
2424+CREATE UNIQUE INDEX `idx_feature_requests_sphere_number` ON `feature_requests` (`sphere_id`,`number`);--> statement-breakpoint
2525+CREATE INDEX `idx_feature_requests_sphere` ON `feature_requests` (`sphere_id`);--> statement-breakpoint
2626+CREATE INDEX `idx_feature_requests_status` ON `feature_requests` (`status`);--> statement-breakpoint
2727+CREATE INDEX `idx_feature_requests_created` ON `feature_requests` (`created_at`);--> statement-breakpoint
2828+CREATE INDEX `idx_feature_requests_category` ON `feature_requests` (`category`);
···44const ROOT = resolve(import.meta.dirname, "../../..");
5566export default function globalSetup() {
77- // Build the frontend for production SSR
88- execSync("bun run build", { stdio: "inherit", cwd: ROOT });
77+ // Build once with MULTI_SPHERE="" — works for both self-hosted and multi-sphere e2e tests because:
88+ // - SSR calls setMultiSphere(data.multiSphere) before rendering
99+ // - Client calls setMultiSphere(ssrData.multiSphere) before hydration
1010+ // The build-time __MULTI_SPHERE__ is only a fallback for dev without SSR.
1111+ execSync("bun run build", { stdio: "inherit", cwd: ROOT, env: { ...process.env, MULTI_SPHERE: "" } });
9121013 // Seed the test database
1114 const seedScript = resolve(import.meta.dirname, "seed.ts");
···11-import { isMultiSphere } from "./config.ts";
22-import { sphereSlug } from "./sphere.ts";
33-41export class ApiError extends Error {
52 constructor(
63 public status: number,
···2118 }
2219 return data as T;
2320}
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-}
+6-1
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()`. */
33+ * Can be overridden per-request during SSR via `setMultiSphere()`.
44+ *
55+ * NOTE: This is module-level mutable state. In SSR, `setMultiSphere` is called
66+ * before each synchronous `prerender()`. This is safe because Bun is single-threaded
77+ * and the render is synchronous. If SSR ever becomes concurrent, this must be
88+ * replaced with a request-scoped context. */
49declare const __MULTI_SPHERE__: boolean;
510export let isMultiSphere: boolean =
611 typeof __MULTI_SPHERE__ !== "undefined" ? __MULTI_SPHERE__ : false;
+16
packages/client/src/module-api.ts
···11+import { isMultiSphere } from "./config.ts";
22+import { sphereHandle } from "./sphere.ts";
33+import { apiFetch } from "./api.ts";
44+55+/**
66+ * Fetch a module API endpoint with automatic sphere-scoping.
77+ * `path` should start with the module name, e.g. "/feature-requests/123".
88+ *
99+ * - Multi-sphere mode: calls `/api/s/{sphereHandle}/{path}`
1010+ * - Self-hosted mode: calls `/api/{path}`
1111+ */
1212+export function moduleFetch<T>(path: string, options?: RequestInit): Promise<T> {
1313+ const handle = sphereHandle.value;
1414+ const base = isMultiSphere && handle ? `/api/s/${handle}` : "/api";
1515+ return apiFetch<T>(`${base}${path}`, options);
1616+}
+6-6
packages/client/src/router.tsx
···44export { default as lazy } from "preact-iso/lazy";
5566import { isMultiSphere } from "./config.ts";
77-import { sphereSlug } from "./sphere.ts";
77+import { sphereHandle } from "./sphere.ts";
8899/** 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) */
1010+ * e.g. spherePath("/infuse") → "/s/leaflet.pub/infuse" (hosted) or "/infuse" (self-hosted) */
1111export 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}`;
1313+ const handle = sphereHandle.value;
1414+ if (!handle) return path;
1515+ // Avoid trailing slash for root path: spherePath("/") → "/s/handle" not "/s/handle/"
1616+ return path === "/" ? `/s/${handle}` : `/s/${handle}${path}`;
1717}
+12-12
packages/client/src/sphere.ts
···1919 error: null,
2020});
21212222-export const sphereSlug = computed(() => sphereState.value.data?.sphere.slug ?? null);
2222+export const sphereHandle = computed(() => sphereState.value.data?.sphere.handle ?? null);
23232424/**
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`.
2525+ * Load a sphere. In multi-sphere mode, pass the handle from the URL.
2626+ * In self-hosted mode (no handle), 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) {
2828+export async function loadSphere(handle?: string) {
2929+ // In multi-sphere mode with no handle, there's no sphere to load (dashboard page)
3030+ if (isMultiSphere && !handle) {
3131 sphereState.value = { pending: false, loading: false, data: null, error: null };
3232 return;
3333 }
···3636 sphereState.value = { ...sphereState.value, loading: true };
3737 }, LOADING_DELAY);
3838 try {
3939- const url = slug ? `/api/spheres/${slug}` : "/api/spheres/current";
3939+ const url = handle ? `/api/spheres/${handle}` : "/api/spheres/current";
4040 const data = await apiFetch<SphereData>(url);
4141 clearTimeout(timer);
4242 sphereState.value = { pending: false, loading: false, data, error: null };
···53535454/** Silent refresh — keeps existing data visible while fetching. */
5555export async function refreshSphere() {
5656- const slug = sphereSlug.value;
5656+ const handle = sphereHandle.value;
5757 // In multi-sphere mode with no current sphere, nothing to refresh
5858- if (isMultiSphere && !slug) return;
5858+ if (isMultiSphere && !handle) return;
5959 try {
6060- const url = slug ? `/api/spheres/${slug}` : "/api/spheres/current";
6060+ const url = handle ? `/api/spheres/${handle}` : "/api/spheres/current";
6161 const data = await apiFetch<SphereData>(url);
6262 sphereState.value = { pending: false, loading: false, data, error: null };
6363 } catch (err) {
···6868 }
6969}
70707171-/** Extract sphere slug from the current URL path in multi-sphere mode. */
7272-export function getSphereSlugFromUrl(): string | null {
7171+/** Extract sphere handle from the current URL path in multi-sphere mode. */
7272+export function getSphereHandleFromUrl(): string | null {
7373 const match = location.pathname.match(/^\/s\/([^/]+)/);
7474 return match?.[1] ?? null;
7575}
···11-import { moduleFetch } from "@exosphere/client/api";
11+import { moduleFetch } from "@exosphere/client/module-api";
22import type { FeatureRequest, FeatureRequestListItem } from "../../types.ts";
3344export type { FeatureRequest, FeatureRequestListItem };
+1-1
packages/feature-requests/src/ui/api/search.ts
···11-import { moduleFetch } from "@exosphere/client/api";
11+import { moduleFetch } from "@exosphere/client/module-api";
2233export function searchFeatureRequests(q: string, excludeId: string) {
44 return moduleFetch<{
+1-1
packages/feature-requests/src/ui/api/status.ts
···11-import { moduleFetch } from "@exosphere/client/api";
11+import { moduleFetch } from "@exosphere/client/module-api";
22import type { FeatureRequestStatus } from "../../types.ts";
3344export type { FeatureRequestStatus };
+1-1
packages/feature-requests/src/ui/api/votes.ts
···11-import { moduleFetch } from "@exosphere/client/api";
11+import { moduleFetch } from "@exosphere/client/module-api";
2233export function voteFeatureRequest(id: string) {
44 return moduleFetch<{ voteCount: number }>(`/feature-requests/${encodeURIComponent(id)}/vote`, {