Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore: review

Hugo 04b46c8a 0e777bd1

+47 -93
+2 -1
packages/client/src/permissions.ts
··· 1 1 import { computed } from "@preact/signals"; 2 2 import { sphereState } from "./sphere.ts"; 3 3 4 - /** Check if the current user can perform an action. */ 4 + /** Check if the current user can perform an action. 5 + * Safe to call directly in Preact render — reads from sphereState signal. */ 5 6 export function canDo(module: string, action: string): boolean { 6 7 const data = sphereState.value.data; 7 8 if (!data?.permissions) return false;
+2 -1
packages/core/src/__tests__/permissions.test.ts
··· 26 26 const SPHERE_ID = "sphere-1"; 27 27 const SPHERE_HANDLE = "test.bsky.social"; 28 28 29 - // Register core permissions once (mirrors server startup) 29 + // The permission registry is a module-level singleton — registrations persist across tests. 30 + // registerModulePermissions silently skips duplicates, so this is safe for watch mode / re-runs. 30 31 beforeAll(() => { 31 32 registerModulePermissions(CORE_MODULE, corePermissions, CORE_PERMISSIONS_COLLECTION); 32 33 });
+1 -24
packages/core/src/__tests__/uri.test.ts
··· 1 - import { describe, it, expect, vi } from "vitest"; 1 + import { describe, it, expect } from "vitest"; 2 2 import { parseAtUri, buildAtUri } from "../indexer/uri.ts"; 3 - 4 - // Mock the DB module to prevent bun:sqlite from being loaded 5 - vi.mock("../db/index.ts", () => ({ getDb: vi.fn() })); 6 - 7 - import { isAdminOrOwner } from "../sphere/operations.ts"; 8 3 9 4 describe("parseAtUri", () => { 10 5 it("parses a valid AT URI", () => { ··· 41 36 expect(buildAtUri(parsed.did, parsed.collection, parsed.rkey)).toBe(uri); 42 37 }); 43 38 }); 44 - 45 - describe("isAdminOrOwner", () => { 46 - it("returns true for owner", () => { 47 - expect(isAdminOrOwner("owner")).toBe(true); 48 - }); 49 - 50 - it("returns true for admin", () => { 51 - expect(isAdminOrOwner("admin")).toBe(true); 52 - }); 53 - 54 - it("returns false for member", () => { 55 - expect(isAdminOrOwner("member")).toBe(false); 56 - }); 57 - 58 - it("returns false for null", () => { 59 - expect(isAdminOrOwner(null)).toBe(false); 60 - }); 61 - });
+5 -18
packages/core/src/sphere/api/modules.ts
··· 7 7 import { requireAuth, type AuthEnv } from "../../auth/index.ts"; 8 8 import { putPdsRecord } from "../../pds.ts"; 9 9 import { enableModuleSchema } from "../schemas.ts"; 10 - import { getActiveMemberRole } from "../operations.ts"; 11 - import { checkPermission } from "../../permissions/check.ts"; 12 10 import { findSphere, getEnabledModules, formatModules } from "./helpers.ts"; 13 11 14 12 const SPHERE_COLLECTION = "site.exosphere.sphere" as const; ··· 50 48 return c.json({ error: "Sphere not found" }, 404); 51 49 } 52 50 53 - const callerRole = getActiveMemberRole(sphere.id, c.var.did); 54 - if (!checkPermission(sphere.id, "sphere", "enable-module", callerRole)) { 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) { 55 53 return c.json({ error: "Forbidden" }, 403); 56 54 } 57 55 ··· 77 75 .onConflictDoNothing() 78 76 .run(); 79 77 80 - // PDS sync requires the owner's session — skip if caller is an admin 81 - if (c.var.did === sphere.ownerDid) { 82 - await syncSpherePds(c, sphere); 83 - } else { 84 - console.info("[modules] PDS sync skipped — caller is not the owner"); 85 - } 78 + await syncSpherePds(c, sphere); 86 79 87 80 return c.json({ 88 81 modules: formatModules(getEnabledModules(sphere.id)), ··· 96 89 return c.json({ error: "Sphere not found" }, 404); 97 90 } 98 91 99 - const callerRole = getActiveMemberRole(sphere.id, c.var.did); 100 - if (!checkPermission(sphere.id, "sphere", "disable-module", callerRole)) { 92 + if (c.var.did !== sphere.ownerDid) { 101 93 return c.json({ error: "Forbidden" }, 403); 102 94 } 103 95 ··· 111 103 ) 112 104 .run(); 113 105 114 - // PDS sync requires the owner's session — skip if caller is an admin 115 - if (c.var.did === sphere.ownerDid) { 116 - await syncSpherePds(c, sphere); 117 - } else { 118 - console.info("[modules] PDS sync skipped — caller is not the owner"); 119 - } 106 + await syncSpherePds(c, sphere); 120 107 121 108 return c.json({ 122 109 modules: formatModules(getEnabledModules(sphere.id)),
+35 -43
packages/core/src/sphere/api/permissions.ts
··· 10 10 getAllModulePermissions, 11 11 getModulePermissionsCollection, 12 12 getRequiredRole, 13 - checkPermission, 14 13 CORE_MODULE, 15 14 type Role, 16 15 } from "../../permissions/index.ts"; 17 - import { getActiveMemberRole } from "../operations.ts"; 18 16 import { findSphere, getEnabledModules } from "./helpers.ts"; 19 17 20 18 const updatePermissionsSchema = z.object({ ··· 23 21 24 22 const app = new Hono<AuthEnv>(); 25 23 26 - // Get full permissions configuration for a sphere (requires update-permissions permission) 24 + // Get full permissions configuration for a sphere (owner only) 27 25 app.get("/:handle/permissions", requireAuth, (c) => { 28 26 const sphere = findSphere(c.req.param("handle")); 29 27 if (!sphere) { 30 28 return c.json({ error: "Sphere not found" }, 404); 31 29 } 32 30 33 - const role = getActiveMemberRole(sphere.id, c.var.did); 34 - if (!checkPermission(sphere.id, "sphere", "update-permissions", role)) { 31 + if (c.var.did !== sphere.ownerDid) { 35 32 return c.json({ error: "Forbidden" }, 403); 36 33 } 37 34 ··· 81 78 return c.json({ error: "Sphere not found" }, 404); 82 79 } 83 80 84 - const callerRole = getActiveMemberRole(sphere.id, c.var.did); 85 - if (!checkPermission(sphere.id, "sphere", "update-permissions", callerRole)) { 81 + // Only the owner can update permissions — overrides are synced to PDS which requires the owner's session 82 + if (c.var.did !== sphere.ownerDid) { 86 83 return c.json({ error: "Forbidden" }, 403); 87 84 } 88 85 ··· 164 161 } 165 162 }); 166 163 167 - // PDS sync requires the owner's session — skip if caller is an admin 168 - if (c.var.did !== sphere.ownerDid) { 169 - console.info("[permissions] PDS sync skipped — caller is not the owner"); 170 - } else { 171 - // Group all overrides by module 172 - const allOverrides = db 173 - .select({ actionKey: spherePermissions.actionKey, minRole: spherePermissions.minRole }) 174 - .from(spherePermissions) 175 - .where(eq(spherePermissions.sphereId, sphere.id)) 176 - .all(); 164 + // Sync all overrides to PDS (caller is guaranteed to be the owner) 165 + const allOverrides = db 166 + .select({ actionKey: spherePermissions.actionKey, minRole: spherePermissions.minRole }) 167 + .from(spherePermissions) 168 + .where(eq(spherePermissions.sphereId, sphere.id)) 169 + .all(); 177 170 178 - const byModule = new Map<string, Record<string, string>>(); 179 - for (const row of allOverrides) { 180 - const parsed = parseActionKey(row.actionKey); 181 - if (!parsed) continue; 182 - if (!byModule.has(parsed.moduleName)) byModule.set(parsed.moduleName, {}); 183 - byModule.get(parsed.moduleName)![parsed.action] = row.minRole; 184 - } 171 + const byModule = new Map<string, Record<string, string>>(); 172 + for (const row of allOverrides) { 173 + const parsed = parseActionKey(row.actionKey); 174 + if (!parsed) continue; 175 + if (!byModule.has(parsed.moduleName)) byModule.set(parsed.moduleName, {}); 176 + byModule.get(parsed.moduleName)![parsed.action] = row.minRole; 177 + } 185 178 186 - // Write (or delete) a PDS record for each module that has a permissions collection 187 - const enabledModules = getEnabledModules(sphere.id).map((m) => m.moduleName); 188 - const allModules = [CORE_MODULE, ...enabledModules]; 189 - for (const moduleName of allModules) { 190 - const collection = getModulePermissionsCollection(moduleName); 191 - if (!collection) continue; 179 + // Write (or delete) a PDS record for each module that has a permissions collection 180 + const enabledModules = getEnabledModules(sphere.id).map((m) => m.moduleName); 181 + const allModules = [CORE_MODULE, ...enabledModules]; 182 + for (const moduleName of allModules) { 183 + const collection = getModulePermissionsCollection(moduleName); 184 + if (!collection) continue; 192 185 193 - const moduleOverrides = byModule.get(moduleName); 194 - if (moduleOverrides && Object.keys(moduleOverrides).length > 0) { 195 - // Type assertions required: collection comes from the permissions registry at runtime 196 - await putPdsRecord( 197 - c.var.session, 198 - collection as keyof PdsRecordMap, 199 - "self", 200 - moduleOverrides as PdsRecordMap[keyof PdsRecordMap], 201 - ); 202 - } else { 203 - // No overrides — delete the record so PDS only holds actual overrides 204 - await deletePdsRecord(c.var.session, collection, "self"); 205 - } 186 + const moduleOverrides = byModule.get(moduleName); 187 + if (moduleOverrides && Object.keys(moduleOverrides).length > 0) { 188 + // Type assertions required: collection comes from the permissions registry at runtime 189 + await putPdsRecord( 190 + c.var.session, 191 + collection as keyof PdsRecordMap, 192 + "self", 193 + moduleOverrides as PdsRecordMap[keyof PdsRecordMap], 194 + ); 195 + } else { 196 + // No overrides — delete the record so PDS only holds actual overrides 197 + await deletePdsRecord(c.var.session, collection, "self"); 206 198 } 207 199 } 208 200
-1
packages/core/src/sphere/index.ts
··· 7 7 export { createCoreIndexer } from "./indexer.ts"; 8 8 export { 9 9 getActiveMemberRole, 10 - isAdminOrOwner, 11 10 registerModerationHandler, 12 11 findSphereByAtUri, 13 12 } from "./operations.ts";
+2 -5
packages/core/src/sphere/operations.ts
··· 3 3 import { spheres, sphereMembers, spherePermissions } from "../db/schema/index.ts"; 4 4 import { parseAtUri } from "../indexer/uri.ts"; 5 5 import { ROLES } from "../permissions/roles.ts"; 6 + import { checkPermission } from "../permissions/check.ts"; 6 7 7 8 // ---- Membership utilities ---- 8 9 ··· 19 20 ) 20 21 .get(); 21 22 return row?.role ?? null; 22 - } 23 - 24 - export function isAdminOrOwner(role: typeof sphereMembers.$inferSelect.role | null): boolean { 25 - return role === "owner" || role === "admin"; 26 23 } 27 24 28 25 /** Get the membership status for a DID in a sphere (any status, not just active). */ ··· 290 287 if (!sphere) return; 291 288 292 289 const role = getActiveMemberRole(sphere.id, did); 293 - if (!isAdminOrOwner(role)) return; 290 + if (!checkPermission(sphere.id, "feature-requests", "moderate", role)) return; 294 291 295 292 for (const handler of moderationHandlers) { 296 293 if (handler(subjectUri, did)) return;