···5252 visibility: string;
5353 /** Module names enabled for this Sphere. */
5454 modules?: string[];
5555- /** Per-module permission overrides. Key: "module:action", value: minimum role. */
5656- permissions?: Record<string, string>;
5755 /** datetime */
5856 createdAt: string;
5757+}
5858+5959+export interface FeatureRequestPermissionsRecord {
6060+ /** Minimum role to create feature requests. */
6161+ create?: string;
6262+ /** Minimum role to vote on feature requests. */
6363+ vote?: string;
6464+ /** Minimum role to comment on feature requests. */
6565+ comment?: string;
6666+ /** Minimum role to change feature request status. */
6767+ "change-status"?: string;
6868+ /** Minimum role to mark feature requests as duplicate. */
6969+ "mark-duplicate"?: string;
7070+ /** Minimum role to hide/unhide content. */
7171+ moderate?: string;
5972}
60736174export interface SphereMemberRecord {
···8093 "site.exosphere.featureRequestVote": FeatureRequestVoteRecord;
8194 "site.exosphere.moderation": ModerationRecord;
8295 "site.exosphere.sphere": SphereRecord;
9696+ "site.exosphere.featureRequestPermissions": FeatureRequestPermissionsRecord;
8397 "site.exosphere.sphereMember": SphereMemberRecord;
8498 "site.exosphere.sphereMemberApproval": SphereMemberApprovalRecord;
8599}
+6-1
packages/core/src/permissions/index.ts
···11export { ROLE_LEVELS, ROLES, hasMinimumRole } from "./roles.ts";
22export type { Role, MemberRole } from "./roles.ts";
33-export { registerModulePermissions, getAllModulePermissions } from "./registry.ts";
33+export {
44+ registerModulePermissions,
55+ getAllModulePermissions,
66+ getModulePermissionsCollection,
77+ getAllPermissionsCollections,
88+} from "./registry.ts";
49export type { ModulePermission } from "./registry.ts";
510export { getRequiredRole, checkPermission, computeUserPermissions } from "./check.ts";
611export { requirePermission } from "./middleware.ts";
+15
packages/core/src/permissions/registry.ts
···88}
991010const modulePermissions = new Map<string, Record<string, ModulePermission>>();
1111+const moduleCollections = new Map<string, string>();
11121213/** Register a module's permission declarations. Called at server startup. */
1314export function registerModulePermissions(
1415 moduleName: string,
1516 perms: Record<string, ModulePermission>,
1717+ permissionsCollection?: string,
1618): void {
1719 modulePermissions.set(moduleName, perms);
2020+ if (permissionsCollection) {
2121+ moduleCollections.set(moduleName, permissionsCollection);
2222+ }
1823}
19242025/** Get the default role for a specific module action. */
···2631export function getAllModulePermissions(): ReadonlyMap<string, Record<string, ModulePermission>> {
2732 return modulePermissions;
2833}
3434+3535+/** Get the PDS collection ID for a module's permissions record. */
3636+export function getModulePermissionsCollection(moduleName: string): string | undefined {
3737+ return moduleCollections.get(moduleName);
3838+}
3939+4040+/** Get all module name → PDS collection mappings. */
4141+export function getAllPermissionsCollections(): ReadonlyMap<string, string> {
4242+ return moduleCollections;
4343+}
+9-2
packages/core/src/sphere/api/members.ts
···1313 activateMember,
1414} from "../operations.ts";
1515import { findSphere } from "./helpers.ts";
1616+import { resolveDidHandles } from "../../identity/index.ts";
16171718const SPHERE_COLLECTION = "site.exosphere.sphere";
1819const MEMBER_COLLECTION = "site.exosphere.sphereMember";
···2122const app = new Hono<AuthEnv>();
22232324// List members of a sphere
2424-app.get("/:handle/members", requireAuth, (c) => {
2525+app.get("/:handle/members", requireAuth, async (c) => {
2526 const sphere = findSphere(c.req.param("handle"));
2627 if (!sphere) {
2728 return c.json({ error: "Sphere not found" }, 404);
···4647 .orderBy(sphereMembers.createdAt)
4748 .all();
48494949- return c.json({ members: rows });
5050+ const handleMap = await resolveDidHandles(rows.map((r) => r.did));
5151+ const members = rows.map((r) => ({
5252+ ...r,
5353+ handle: handleMap.get(r.did) ?? null,
5454+ }));
5555+5656+ return c.json({ members });
5057});
51585259// Invite a member (admin/owner only)
+30-15
packages/core/src/sphere/api/permissions.ts
···44import { getDb } from "../../db/index.ts";
55import { spherePermissions } from "../../db/schema/index.ts";
66import { requireAuth, type AuthEnv } from "../../auth/index.ts";
77-import { putPdsRecord } from "../../pds.ts";
77+import { putPdsRecord, deletePdsRecord } from "../../pds.ts";
88+import type { PdsRecordMap } from "../../generated/lexicon-records.ts";
89import {
910 getAllModulePermissions,
1111+ getModulePermissionsCollection,
1012 getRequiredRole,
1113 type Role,
1214} from "../../permissions/index.ts";
1315import { getActiveMemberRole, isAdminOrOwner } from "../operations.ts";
1416import { findSphere, getEnabledModules } from "./helpers.ts";
1515-1616-const SPHERE_COLLECTION = "site.exosphere.sphere" as const;
17171818const updatePermissionsSchema = z.object({
1919 overrides: z.record(z.string(), z.enum(["owner", "admin", "member", "authenticated"])),
···136136 }
137137 });
138138139139- // Build the permissions map for PDS sync (only overrides)
139139+ // Sync per-module permission records to PDS
140140+ // Group all overrides by module
140141 const allOverrides = db
141142 .select({ actionKey: spherePermissions.actionKey, minRole: spherePermissions.minRole })
142143 .from(spherePermissions)
143144 .where(eq(spherePermissions.sphereId, sphere.id))
144145 .all();
145146146146- const permissionsMap: Record<string, string> = {};
147147+ const byModule = new Map<string, Record<string, string>>();
147148 for (const row of allOverrides) {
148148- permissionsMap[row.actionKey] = row.minRole;
149149+ const sepIdx = row.actionKey.indexOf(":");
150150+ if (sepIdx === -1) continue;
151151+ const moduleName = row.actionKey.slice(0, sepIdx);
152152+ const action = row.actionKey.slice(sepIdx + 1);
153153+ if (!byModule.has(moduleName)) byModule.set(moduleName, {});
154154+ byModule.get(moduleName)![action] = row.minRole;
149155 }
150156151151- // Sync sphere record to PDS
157157+ // Write (or delete) a PDS record for each module that has a permissions collection
152158 const enabledModules = getEnabledModules(sphere.id).map((m) => m.moduleName);
153153- await putPdsRecord(c.var.session, SPHERE_COLLECTION, "self", {
154154- name: sphere.name,
155155- description: sphere.description ?? undefined,
156156- visibility: sphere.visibility,
157157- modules: enabledModules,
158158- permissions: Object.keys(permissionsMap).length > 0 ? permissionsMap : undefined,
159159- createdAt: sphere.createdAt,
160160- });
159159+ for (const moduleName of enabledModules) {
160160+ const collection = getModulePermissionsCollection(moduleName);
161161+ if (!collection) continue;
162162+163163+ const moduleOverrides = byModule.get(moduleName);
164164+ if (moduleOverrides && Object.keys(moduleOverrides).length > 0) {
165165+ await putPdsRecord(
166166+ c.var.session,
167167+ collection as "site.exosphere.featureRequestPermissions",
168168+ "self",
169169+ moduleOverrides as PdsRecordMap["site.exosphere.featureRequestPermissions"],
170170+ );
171171+ } else {
172172+ // No overrides — delete the record so PDS only holds actual overrides
173173+ await deletePdsRecord(c.var.session, collection, "self");
174174+ }
175175+ }
161176162177 return c.json({ ok: true });
163178});
···11-import { eq, and } from "drizzle-orm";
11+import { eq, and, like } from "drizzle-orm";
22import { getDb } from "../db/index.ts";
33import { spheres, sphereMembers, spherePermissions } from "../db/schema/index.ts";
44import { parseAtUri } from "../indexer/uri.ts";
55-import { ROLES, type Role } from "../permissions/roles.ts";
55+import { ROLES } from "../permissions/roles.ts";
6677// ---- Membership utilities ----
88···23232424export function isAdminOrOwner(role: typeof sphereMembers.$inferSelect.role | null): boolean {
2525 return role === "owner" || role === "admin";
2626+}
2727+2828+/** Get the membership status for a DID in a sphere (any status, not just active). */
2929+export function getMemberStatus(sphereId: string, did: string) {
3030+ const row = getDb()
3131+ .select({ role: sphereMembers.role, status: sphereMembers.status })
3232+ .from(sphereMembers)
3333+ .where(
3434+ and(eq(sphereMembers.sphereId, sphereId), eq(sphereMembers.did, did)),
3535+ )
3636+ .get();
3737+ return row ?? null;
2638}
27392840const VALID_ROLES = ["owner", "admin", "member"] as const;
···7486 set.updatedAt = new Date().toISOString();
75877688 db.update(spheres).set(set).where(eq(spheres.id, existing.id)).run();
7777-7878- // Sync permission overrides from PDS
7979- if (record.permissions && typeof record.permissions === "object") {
8080- const perms = record.permissions as Record<string, string>;
8181- const validRoles = new Set<string>(ROLES);
8282-8383- // Clear existing overrides and replace
8484- db.delete(spherePermissions)
8585- .where(eq(spherePermissions.sphereId, existing.id))
8686- .run();
8787-8888- for (const [actionKey, minRole] of Object.entries(perms)) {
8989- if (typeof actionKey === "string" && actionKey.includes(":") && validRoles.has(minRole)) {
9090- db.insert(spherePermissions)
9191- .values({ sphereId: existing.id, actionKey, minRole })
9292- .onConflictDoNothing()
9393- .run();
9494- }
9595- }
9696- }
9789 } else {
9890 // Ignore sphere records for DIDs not on this instance —
9991 // spheres are created locally, not via Jetstream from other instances.
···214206 .update(sphereMembers)
215207 .set({ pdsUri: null })
216208 .where(and(eq(sphereMembers.did, did), eq(sphereMembers.pdsUri, pdsUri)))
209209+ .run();
210210+}
211211+212212+// ---- Per-module permission sync from PDS ----
213213+214214+function findSphereByOwner(did: string): { id: string } | undefined {
215215+ return getDb()
216216+ .select({ id: spheres.id })
217217+ .from(spheres)
218218+ .where(eq(spheres.ownerDid, did))
219219+ .get();
220220+}
221221+222222+/** Sync a module's permission overrides from a PDS record into the local DB. */
223223+export function syncModulePermissionsFromRecord(
224224+ did: string,
225225+ moduleName: string,
226226+ record: Record<string, unknown>,
227227+): void {
228228+ const sphere = findSphereByOwner(did);
229229+ if (!sphere) return;
230230+231231+ const db = getDb();
232232+233233+ const validRoles = new Set<string>(ROLES);
234234+ const prefix = `${moduleName}:`;
235235+236236+ db.transaction((tx) => {
237237+ // Clear existing overrides for this module
238238+ tx.delete(spherePermissions)
239239+ .where(
240240+ and(
241241+ eq(spherePermissions.sphereId, sphere.id),
242242+ like(spherePermissions.actionKey, `${prefix}%`),
243243+ ),
244244+ )
245245+ .run();
246246+247247+ // Insert new overrides
248248+ for (const [action, minRole] of Object.entries(record)) {
249249+ if (action === "$type") continue;
250250+ if (typeof minRole !== "string" || !validRoles.has(minRole)) continue;
251251+252252+ tx.insert(spherePermissions)
253253+ .values({ sphereId: sphere.id, actionKey: `${prefix}${action}`, minRole })
254254+ .onConflictDoNothing()
255255+ .run();
256256+ }
257257+ });
258258+}
259259+260260+/** Delete all permission overrides for a module when its PDS record is deleted. */
261261+export function deleteModulePermissions(did: string, moduleName: string): void {
262262+ const sphere = findSphereByOwner(did);
263263+ if (!sphere) return;
264264+265265+ getDb().delete(spherePermissions)
266266+ .where(
267267+ and(
268268+ eq(spherePermissions.sphereId, sphere.id),
269269+ like(spherePermissions.actionKey, `${moduleName}:%`),
270270+ ),
271271+ )
217272 .run();
218273}
219274
+1-1
packages/core/src/sphere/routes.ts
···55import { membersApi } from "./api/members.ts";
66import { permissionsApi } from "./api/permissions.ts";
7788-export { getCurrentSphere, getMemberSpheres } from "./api/spheres.ts";
88+export { getCurrentSphere, getMemberSpheres, getPendingInvitations } from "./api/spheres.ts";
991010export function createSphereRoutes(availableModules: string[]) {
1111 const app = new Hono<AuthEnv>();
+4
packages/core/src/types/index.ts
···4545 indexer?: ModuleIndexer;
4646 /** Named actions and their default minimum role, displayed in the admin panel */
4747 permissions?: Record<string, ModulePermission>;
4848+ /** AT Protocol collection ID for this module's permission overrides record (e.g. "site.exosphere.featureRequestPermissions") */
4949+ permissionsCollection?: string;
4850}
49515052/** Formatted module info as returned by the API (subset of SphereModule DB row). */
···5961 modules: SphereModuleInfo[];
6062 memberCount: number;
6163 role: SphereMember["role"] | null;
6464+ /** Membership status — null if not a member, "invited" if pending, "active" if accepted. */
6565+ memberStatus: SphereMember["status"] | null;
6266 /** Pre-computed permission map for the current user: "module:action" → boolean. */
6367 permissions?: Record<string, boolean>;
6468}