···11import { computed } from "@preact/signals";
22import { sphereState } from "./sphere.ts";
3344-/** Check if the current user can perform an action. */
44+/** Check if the current user can perform an action.
55+ * Safe to call directly in Preact render — reads from sphereState signal. */
56export function canDo(module: string, action: string): boolean {
67 const data = sphereState.value.data;
78 if (!data?.permissions) return false;
+2-1
packages/core/src/__tests__/permissions.test.ts
···2626const SPHERE_ID = "sphere-1";
2727const SPHERE_HANDLE = "test.bsky.social";
28282929-// Register core permissions once (mirrors server startup)
2929+// The permission registry is a module-level singleton — registrations persist across tests.
3030+// registerModulePermissions silently skips duplicates, so this is safe for watch mode / re-runs.
3031beforeAll(() => {
3132 registerModulePermissions(CORE_MODULE, corePermissions, CORE_PERMISSIONS_COLLECTION);
3233});
+1-24
packages/core/src/__tests__/uri.test.ts
···11-import { describe, it, expect, vi } from "vitest";
11+import { describe, it, expect } from "vitest";
22import { parseAtUri, buildAtUri } from "../indexer/uri.ts";
33-44-// Mock the DB module to prevent bun:sqlite from being loaded
55-vi.mock("../db/index.ts", () => ({ getDb: vi.fn() }));
66-77-import { isAdminOrOwner } from "../sphere/operations.ts";
8394describe("parseAtUri", () => {
105 it("parses a valid AT URI", () => {
···4136 expect(buildAtUri(parsed.did, parsed.collection, parsed.rkey)).toBe(uri);
4237 });
4338});
4444-4545-describe("isAdminOrOwner", () => {
4646- it("returns true for owner", () => {
4747- expect(isAdminOrOwner("owner")).toBe(true);
4848- });
4949-5050- it("returns true for admin", () => {
5151- expect(isAdminOrOwner("admin")).toBe(true);
5252- });
5353-5454- it("returns false for member", () => {
5555- expect(isAdminOrOwner("member")).toBe(false);
5656- });
5757-5858- it("returns false for null", () => {
5959- expect(isAdminOrOwner(null)).toBe(false);
6060- });
6161-});
+5-18
packages/core/src/sphere/api/modules.ts
···77import { requireAuth, type AuthEnv } from "../../auth/index.ts";
88import { putPdsRecord } from "../../pds.ts";
99import { enableModuleSchema } from "../schemas.ts";
1010-import { getActiveMemberRole } from "../operations.ts";
1111-import { checkPermission } from "../../permissions/check.ts";
1210import { findSphere, getEnabledModules, formatModules } from "./helpers.ts";
13111412const SPHERE_COLLECTION = "site.exosphere.sphere" as const;
···5048 return c.json({ error: "Sphere not found" }, 404);
5149 }
52505353- const callerRole = getActiveMemberRole(sphere.id, c.var.did);
5454- if (!checkPermission(sphere.id, "sphere", "enable-module", callerRole)) {
5151+ // Only the owner can manage modules — changes are synced to PDS which requires the owner's session
5252+ if (c.var.did !== sphere.ownerDid) {
5553 return c.json({ error: "Forbidden" }, 403);
5654 }
5755···7775 .onConflictDoNothing()
7876 .run();
79778080- // PDS sync requires the owner's session — skip if caller is an admin
8181- if (c.var.did === sphere.ownerDid) {
8282- await syncSpherePds(c, sphere);
8383- } else {
8484- console.info("[modules] PDS sync skipped — caller is not the owner");
8585- }
7878+ await syncSpherePds(c, sphere);
86798780 return c.json({
8881 modules: formatModules(getEnabledModules(sphere.id)),
···9689 return c.json({ error: "Sphere not found" }, 404);
9790 }
98919999- const callerRole = getActiveMemberRole(sphere.id, c.var.did);
100100- if (!checkPermission(sphere.id, "sphere", "disable-module", callerRole)) {
9292+ if (c.var.did !== sphere.ownerDid) {
10193 return c.json({ error: "Forbidden" }, 403);
10294 }
10395···111103 )
112104 .run();
113105114114- // PDS sync requires the owner's session — skip if caller is an admin
115115- if (c.var.did === sphere.ownerDid) {
116116- await syncSpherePds(c, sphere);
117117- } else {
118118- console.info("[modules] PDS sync skipped — caller is not the owner");
119119- }
106106+ await syncSpherePds(c, sphere);
120107121108 return c.json({
122109 modules: formatModules(getEnabledModules(sphere.id)),
+35-43
packages/core/src/sphere/api/permissions.ts
···1010 getAllModulePermissions,
1111 getModulePermissionsCollection,
1212 getRequiredRole,
1313- checkPermission,
1413 CORE_MODULE,
1514 type Role,
1615} from "../../permissions/index.ts";
1717-import { getActiveMemberRole } from "../operations.ts";
1816import { findSphere, getEnabledModules } from "./helpers.ts";
19172018const updatePermissionsSchema = z.object({
···23212422const app = new Hono<AuthEnv>();
25232626-// Get full permissions configuration for a sphere (requires update-permissions permission)
2424+// Get full permissions configuration for a sphere (owner only)
2725app.get("/:handle/permissions", requireAuth, (c) => {
2826 const sphere = findSphere(c.req.param("handle"));
2927 if (!sphere) {
3028 return c.json({ error: "Sphere not found" }, 404);
3129 }
32303333- const role = getActiveMemberRole(sphere.id, c.var.did);
3434- if (!checkPermission(sphere.id, "sphere", "update-permissions", role)) {
3131+ if (c.var.did !== sphere.ownerDid) {
3532 return c.json({ error: "Forbidden" }, 403);
3633 }
3734···8178 return c.json({ error: "Sphere not found" }, 404);
8279 }
83808484- const callerRole = getActiveMemberRole(sphere.id, c.var.did);
8585- if (!checkPermission(sphere.id, "sphere", "update-permissions", callerRole)) {
8181+ // Only the owner can update permissions — overrides are synced to PDS which requires the owner's session
8282+ if (c.var.did !== sphere.ownerDid) {
8683 return c.json({ error: "Forbidden" }, 403);
8784 }
8885···164161 }
165162 });
166163167167- // PDS sync requires the owner's session — skip if caller is an admin
168168- if (c.var.did !== sphere.ownerDid) {
169169- console.info("[permissions] PDS sync skipped — caller is not the owner");
170170- } else {
171171- // Group all overrides by module
172172- const allOverrides = db
173173- .select({ actionKey: spherePermissions.actionKey, minRole: spherePermissions.minRole })
174174- .from(spherePermissions)
175175- .where(eq(spherePermissions.sphereId, sphere.id))
176176- .all();
164164+ // Sync all overrides to PDS (caller is guaranteed to be the owner)
165165+ const allOverrides = db
166166+ .select({ actionKey: spherePermissions.actionKey, minRole: spherePermissions.minRole })
167167+ .from(spherePermissions)
168168+ .where(eq(spherePermissions.sphereId, sphere.id))
169169+ .all();
177170178178- const byModule = new Map<string, Record<string, string>>();
179179- for (const row of allOverrides) {
180180- const parsed = parseActionKey(row.actionKey);
181181- if (!parsed) continue;
182182- if (!byModule.has(parsed.moduleName)) byModule.set(parsed.moduleName, {});
183183- byModule.get(parsed.moduleName)![parsed.action] = row.minRole;
184184- }
171171+ const byModule = new Map<string, Record<string, string>>();
172172+ for (const row of allOverrides) {
173173+ const parsed = parseActionKey(row.actionKey);
174174+ if (!parsed) continue;
175175+ if (!byModule.has(parsed.moduleName)) byModule.set(parsed.moduleName, {});
176176+ byModule.get(parsed.moduleName)![parsed.action] = row.minRole;
177177+ }
185178186186- // Write (or delete) a PDS record for each module that has a permissions collection
187187- const enabledModules = getEnabledModules(sphere.id).map((m) => m.moduleName);
188188- const allModules = [CORE_MODULE, ...enabledModules];
189189- for (const moduleName of allModules) {
190190- const collection = getModulePermissionsCollection(moduleName);
191191- if (!collection) continue;
179179+ // Write (or delete) a PDS record for each module that has a permissions collection
180180+ const enabledModules = getEnabledModules(sphere.id).map((m) => m.moduleName);
181181+ const allModules = [CORE_MODULE, ...enabledModules];
182182+ for (const moduleName of allModules) {
183183+ const collection = getModulePermissionsCollection(moduleName);
184184+ if (!collection) continue;
192185193193- const moduleOverrides = byModule.get(moduleName);
194194- if (moduleOverrides && Object.keys(moduleOverrides).length > 0) {
195195- // Type assertions required: collection comes from the permissions registry at runtime
196196- await putPdsRecord(
197197- c.var.session,
198198- collection as keyof PdsRecordMap,
199199- "self",
200200- moduleOverrides as PdsRecordMap[keyof PdsRecordMap],
201201- );
202202- } else {
203203- // No overrides — delete the record so PDS only holds actual overrides
204204- await deletePdsRecord(c.var.session, collection, "self");
205205- }
186186+ const moduleOverrides = byModule.get(moduleName);
187187+ if (moduleOverrides && Object.keys(moduleOverrides).length > 0) {
188188+ // Type assertions required: collection comes from the permissions registry at runtime
189189+ await putPdsRecord(
190190+ c.var.session,
191191+ collection as keyof PdsRecordMap,
192192+ "self",
193193+ moduleOverrides as PdsRecordMap[keyof PdsRecordMap],
194194+ );
195195+ } else {
196196+ // No overrides — delete the record so PDS only holds actual overrides
197197+ await deletePdsRecord(c.var.session, collection, "self");
206198 }
207199 }
208200
-1
packages/core/src/sphere/index.ts
···77export { createCoreIndexer } from "./indexer.ts";
88export {
99 getActiveMemberRole,
1010- isAdminOrOwner,
1110 registerModerationHandler,
1211 findSphereByAtUri,
1312} from "./operations.ts";
+2-5
packages/core/src/sphere/operations.ts
···33import { spheres, sphereMembers, spherePermissions } from "../db/schema/index.ts";
44import { parseAtUri } from "../indexer/uri.ts";
55import { ROLES } from "../permissions/roles.ts";
66+import { checkPermission } from "../permissions/check.ts";
6778// ---- Membership utilities ----
89···1920 )
2021 .get();
2122 return row?.role ?? null;
2222-}
2323-2424-export function isAdminOrOwner(role: typeof sphereMembers.$inferSelect.role | null): boolean {
2525- return role === "owner" || role === "admin";
2623}
27242825/** Get the membership status for a DID in a sphere (any status, not just active). */
···290287 if (!sphere) return;
291288292289 const role = getActiveMemberRole(sphere.id, did);
293293- if (!isAdminOrOwner(role)) return;
290290+ if (!checkPermission(sphere.id, "feature-requests", "moderate", role)) return;
294291295292 for (const handler of moderationHandlers) {
296293 if (handler(subjectUri, did)) return;