Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol.
app.exosphere.site
1import type { Context } from "hono";
2import { Hono } from "hono";
3import { z } from "zod";
4import { and, eq } from "../../db/drizzle.ts";
5import { getDb } from "../../db/index.ts";
6import { sphereModules, type spheres } from "../../db/schema/index.ts";
7import { requireAuth, type AuthEnv } from "../../auth/index.ts";
8import { putPdsRecord } from "../../pds.ts";
9import { enableModuleSchema } from "../schemas.ts";
10import { findSphere, getEnabledModules, formatModules } from "./helpers.ts";
11
12const SPHERE_COLLECTION = "site.exosphere.sphere.profile" as const;
13
14type Sphere = typeof spheres.$inferSelect;
15
16/** Sync the sphere declaration (including modules) to the owner's PDS. */
17async function syncSpherePds(c: Context<AuthEnv>, sphere: Sphere) {
18 const modules = getEnabledModules(sphere.id).map((m) => m.moduleName);
19 await putPdsRecord(c.var.session, SPHERE_COLLECTION, "self", {
20 name: sphere.name,
21 description: sphere.description ?? undefined,
22 visibility: sphere.visibility,
23 modules,
24 createdAt: sphere.createdAt,
25 });
26}
27
28export function createModuleRoutes(availableModules: string[]) {
29 const app = new Hono<AuthEnv>();
30
31 // List enabled modules for a sphere
32 app.get("/:handle/modules", (c) => {
33 const sphere = findSphere(c.req.param("handle"));
34 if (!sphere) {
35 return c.json({ error: "Sphere not found" }, 404);
36 }
37
38 return c.json({
39 modules: formatModules(getEnabledModules(sphere.id)),
40 available: availableModules,
41 });
42 });
43
44 // Enable module
45 app.post("/:handle/modules", requireAuth, async (c) => {
46 const sphere = findSphere(c.req.param("handle"));
47 if (!sphere) {
48 return c.json({ error: "Sphere not found" }, 404);
49 }
50
51 // Only the owner can manage modules — changes are synced to PDS which requires the owner's session
52 if (c.var.did !== sphere.ownerDid) {
53 return c.json({ error: "Forbidden" }, 403);
54 }
55
56 const body = await c.req.json();
57 const parsed = enableModuleSchema.safeParse(body);
58 if (!parsed.success) {
59 return c.json({ error: z.flattenError(parsed.error) }, 400);
60 }
61
62 const moduleName = parsed.data.module;
63 if (!availableModules.includes(moduleName)) {
64 return c.json(
65 {
66 error: `Unknown module: ${moduleName}. Available: ${availableModules.join(", ")}`,
67 },
68 400,
69 );
70 }
71
72 getDb()
73 .insert(sphereModules)
74 .values({ sphereId: sphere.id, moduleName })
75 .onConflictDoNothing()
76 .run();
77
78 await syncSpherePds(c, sphere);
79
80 return c.json({
81 modules: formatModules(getEnabledModules(sphere.id)),
82 });
83 });
84
85 // Disable module
86 app.delete("/:handle/modules/:moduleName", requireAuth, async (c) => {
87 const sphere = findSphere(c.req.param("handle"));
88 if (!sphere) {
89 return c.json({ error: "Sphere not found" }, 404);
90 }
91
92 if (c.var.did !== sphere.ownerDid) {
93 return c.json({ error: "Forbidden" }, 403);
94 }
95
96 getDb()
97 .delete(sphereModules)
98 .where(
99 and(
100 eq(sphereModules.sphereId, sphere.id),
101 eq(sphereModules.moduleName, c.req.param("moduleName")),
102 ),
103 )
104 .run();
105
106 await syncSpherePds(c, sphere);
107
108 return c.json({
109 modules: formatModules(getEnabledModules(sphere.id)),
110 });
111 });
112
113 return app;
114}