···11+CREATE TABLE IF NOT EXISTS `sphere_permissions` (
22+ `sphere_id` text NOT NULL,
33+ `action_key` text NOT NULL,
44+ `min_role` text NOT NULL,
55+ `updated_at` text DEFAULT (datetime('now')) NOT NULL,
66+ PRIMARY KEY(`sphere_id`, `action_key`),
77+ FOREIGN KEY (`sphere_id`) REFERENCES `spheres`(`id`) ON UPDATE no action ON DELETE no action
88+);
+1
drizzle/0003_young_stone_men.sql
···11+ALTER TABLE `spheres` DROP COLUMN `write_access`;
···11+import { computed } from "@preact/signals";
22+import { sphereState } from "./sphere.ts";
33+44+/** Check if the current user can perform an action. */
55+export function canDo(module: string, action: string): boolean {
66+ const data = sphereState.value.data;
77+ if (!data?.permissions) return false;
88+ return data.permissions[`${module}:${action}`] === true;
99+}
1010+1111+/** Reactive computed for use in Preact components. */
1212+export function useCanDo(module: string, action: string) {
1313+ return computed(() => canDo(module, action));
1414+}
···5050 description?: string;
5151 /** Whether the Sphere's content is publicly readable. */
5252 visibility: string;
5353- /** Who can create content in this Sphere. */
5454- writeAccess: string;
5553 /** Module names enabled for this Sphere. */
5654 modules?: string[];
5555+ /** Per-module permission overrides. Key: "module:action", value: minimum role. */
5656+ permissions?: Record<string, string>;
5757 /** datetime */
5858 createdAt: string;
5959}
+70
packages/core/src/permissions/check.ts
···11+import { eq, and } from "../db/drizzle.ts";
22+import { getDb } from "../db/index.ts";
33+import { spherePermissions } from "../db/schema/index.ts";
44+import { hasMinimumRole, type Role, type MemberRole } from "./roles.ts";
55+import { getDefaultRole, getAllModulePermissions } from "./registry.ts";
66+77+/** Resolve the effective minimum role for an action in a sphere. */
88+export function getRequiredRole(sphereId: string, moduleName: string, action: string): Role {
99+ const actionKey = `${moduleName}:${action}`;
1010+1111+ // 1. Check explicit override
1212+ const override = getDb()
1313+ .select({ minRole: spherePermissions.minRole })
1414+ .from(spherePermissions)
1515+ .where(
1616+ and(eq(spherePermissions.sphereId, sphereId), eq(spherePermissions.actionKey, actionKey)),
1717+ )
1818+ .get();
1919+ if (override) return override.minRole as Role;
2020+2121+ // 2. Module default
2222+ const defaultRole = getDefaultRole(moduleName, action);
2323+ if (defaultRole) return defaultRole;
2424+2525+ // 3. Safe fallback
2626+ return "admin";
2727+}
2828+2929+/** Check if a user has permission to perform an action. */
3030+export function checkPermission(
3131+ sphereId: string,
3232+ moduleName: string,
3333+ action: string,
3434+ userRole: MemberRole | null,
3535+): boolean {
3636+ const required = getRequiredRole(sphereId, moduleName, action);
3737+ return hasMinimumRole(userRole, required);
3838+}
3939+4040+/** Compute all permissions for a user in a sphere (for client-side SphereData).
4141+ * Uses a single DB query for all overrides instead of one per action. */
4242+export function computeUserPermissions(
4343+ sphereId: string,
4444+ userRole: MemberRole | null,
4545+ enabledModules: string[],
4646+): Record<string, boolean> {
4747+ const allPerms = getAllModulePermissions();
4848+4949+ // Single query for all overrides in this sphere
5050+ const overrides = getDb()
5151+ .select({ actionKey: spherePermissions.actionKey, minRole: spherePermissions.minRole })
5252+ .from(spherePermissions)
5353+ .where(eq(spherePermissions.sphereId, sphereId))
5454+ .all();
5555+ const overrideMap = new Map(overrides.map((r) => [r.actionKey, r.minRole as Role]));
5656+5757+ const result: Record<string, boolean> = {};
5858+5959+ for (const moduleName of enabledModules) {
6060+ const modulePerms = allPerms.get(moduleName);
6161+ if (!modulePerms) continue;
6262+ for (const [action, perm] of Object.entries(modulePerms)) {
6363+ const key = `${moduleName}:${action}`;
6464+ const required = overrideMap.get(key) ?? perm.defaultRole;
6565+ result[key] = hasMinimumRole(userRole, required);
6666+ }
6767+ }
6868+6969+ return result;
7070+}
+6
packages/core/src/permissions/index.ts
···11+export { ROLE_LEVELS, ROLES, hasMinimumRole } from "./roles.ts";
22+export type { Role, MemberRole } from "./roles.ts";
33+export { registerModulePermissions, getAllModulePermissions } from "./registry.ts";
44+export type { ModulePermission } from "./registry.ts";
55+export { getRequiredRole, checkPermission, computeUserPermissions } from "./check.ts";
66+export { requirePermission } from "./middleware.ts";
+20
packages/core/src/permissions/middleware.ts
···11+import { createMiddleware } from "hono/factory";
22+import type { AuthEnv } from "../auth/middleware.ts";
33+import type { SphereEnv } from "../types/index.ts";
44+import { getActiveMemberRole } from "../sphere/operations.ts";
55+import { checkPermission } from "./check.ts";
66+77+/** Hono middleware factory that checks if the authenticated user has a specific permission. */
88+export function requirePermission(moduleName: string, action: string) {
99+ return createMiddleware<AuthEnv & SphereEnv>(async (c, next) => {
1010+ const did = c.var.did;
1111+ const sphereId = c.var.sphereId;
1212+ const userRole = getActiveMemberRole(sphereId, did);
1313+1414+ if (!checkPermission(sphereId, moduleName, action, userRole)) {
1515+ return c.json({ error: "Forbidden" }, 403);
1616+ }
1717+1818+ await next();
1919+ });
2020+}
+28
packages/core/src/permissions/registry.ts
···11+import type { Role } from "./roles.ts";
22+33+export interface ModulePermission {
44+ /** Human-readable label shown in admin UI */
55+ label: string;
66+ /** Default minimum role needed */
77+ defaultRole: Role;
88+}
99+1010+const modulePermissions = new Map<string, Record<string, ModulePermission>>();
1111+1212+/** Register a module's permission declarations. Called at server startup. */
1313+export function registerModulePermissions(
1414+ moduleName: string,
1515+ perms: Record<string, ModulePermission>,
1616+): void {
1717+ modulePermissions.set(moduleName, perms);
1818+}
1919+2020+/** Get the default role for a specific module action. */
2121+export function getDefaultRole(moduleName: string, action: string): Role | null {
2222+ return modulePermissions.get(moduleName)?.[action]?.defaultRole ?? null;
2323+}
2424+2525+/** Get all registered module permissions (for admin API). */
2626+export function getAllModulePermissions(): ReadonlyMap<string, Record<string, ModulePermission>> {
2727+ return modulePermissions;
2828+}
+22
packages/core/src/permissions/roles.ts
···11+/** Role hierarchy: owner > admin > member > authenticated */
22+export const ROLE_LEVELS = {
33+ owner: 4,
44+ admin: 3,
55+ member: 2,
66+ authenticated: 1,
77+} as const;
88+99+/** Any role that can be set as a minimum permission level. */
1010+export type Role = keyof typeof ROLE_LEVELS;
1111+1212+/** Roles stored in the sphere_members table. */
1313+export type MemberRole = "owner" | "admin" | "member";
1414+1515+/** Check if a user's actual role meets or exceeds the required minimum role. */
1616+export function hasMinimumRole(userRole: MemberRole | null, requiredRole: Role): boolean {
1717+ if (requiredRole === "authenticated") return true;
1818+ if (userRole === null) return false;
1919+ return ROLE_LEVELS[userRole] >= ROLE_LEVELS[requiredRole];
2020+}
2121+2222+export const ROLES: Role[] = ["owner", "admin", "member", "authenticated"];