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.

feat: save permissions on atproto

Hugo e41295f9 589754b1

+439 -95
+20
packages/app/src/api/spheres.ts
··· 68 68 }); 69 69 } 70 70 71 + // ---- Invitations ---- 72 + 73 + export interface InvitationData { 74 + sphere: { id: string; handle: string; name: string }; 75 + role: string; 76 + createdAt: string; 77 + } 78 + 79 + export function getInvitations() { 80 + return apiFetch<{ invitations: InvitationData[] }>("/api/spheres/invitations"); 81 + } 82 + 83 + export function acceptInvite(handle: string) { 84 + return apiFetch<{ ok: true; status: string }>( 85 + `/api/spheres/${encodeURIComponent(handle)}/members/accept`, 86 + { method: "POST" }, 87 + ); 88 + } 89 + 71 90 // ---- Members ---- 72 91 73 92 export interface MemberData { 74 93 did: string; 94 + handle: string | null; 75 95 role: "owner" | "admin" | "member"; 76 96 status: "active" | "invited" | "revoked"; 77 97 invitedBy: string | null;
+2
packages/app/src/app.tsx
··· 7 7 import { isMultiSphere } from "@exosphere/client/config"; 8 8 import * as ui from "@exosphere/client/ui.css"; 9 9 import { ThemeToggle } from "@exosphere/client/components/theme-toggle"; 10 + import { InvitationBanner } from "@exosphere/client/components/invitation-banner"; 10 11 import { Sun, Moon, Monitor } from "lucide-preact"; 11 12 import { SignIn } from "./pages/sign-in.tsx"; 12 13 import { CreateSphere } from "./pages/create-sphere.tsx"; ··· 227 228 <div class={ui.themeRoot}> 228 229 <Header /> 229 230 <div class={ui.mainContent}> 231 + <InvitationBanner /> 230 232 <MainContent moduleRoutes={moduleRoutes} /> 231 233 </div> 232 234 <Footer />
+58 -2
packages/app/src/pages/dashboard.tsx
··· 1 + import { signal, useSignal } from "@preact/signals"; 1 2 import { useQuery } from "@exosphere/client/hooks"; 2 3 import { apiFetch } from "@exosphere/client/api"; 3 4 import { ssrPageData } from "@exosphere/client/ssr-data"; 4 5 import { auth } from "@exosphere/client/auth"; 5 6 import * as ui from "@exosphere/client/ui.css"; 6 7 import * as s from "./dashboard.css.ts"; 8 + import { getInvitations, acceptInvite, type InvitationData } from "../api/spheres.ts"; 7 9 8 10 interface SphereListItem { 9 11 id: string; ··· 20 22 return apiFetch<MySpheresData>("/api/spheres?member=true"); 21 23 } 22 24 25 + const sphereListVersion = signal(0); 26 + 23 27 function MySpheres() { 24 28 const ssrData = ssrPageData.value?.["my-spheres"] as MySpheresData | undefined; 25 - const { data, pending } = useQuery(() => getMySpheres(), [], { 26 - initialData: ssrData, 29 + const version = sphereListVersion.value; 30 + const { data, pending } = useQuery(() => getMySpheres(), [version], { 31 + initialData: version === 0 ? ssrData : undefined, 27 32 }); 28 33 29 34 const spheres = data?.spheres.filter((sp) => sp.role === "owner" || sp.role === "admin") ?? []; ··· 48 53 ); 49 54 } 50 55 56 + type InvitationsData = { invitations: InvitationData[] }; 57 + 58 + function PendingInvitations() { 59 + const ssrData = ssrPageData.value?.["invitations"] as InvitationsData | undefined; 60 + const { data, refetch } = useQuery(() => getInvitations(), [], { 61 + initialData: ssrData, 62 + }); 63 + const accepting = useSignal<string | null>(null); 64 + 65 + if (!data || data.invitations.length === 0) return null; 66 + 67 + const handleAccept = async (inv: InvitationData) => { 68 + accepting.value = inv.sphere.handle; 69 + try { 70 + await acceptInvite(inv.sphere.handle); 71 + refetch(); 72 + sphereListVersion.value++; 73 + } catch (err) { 74 + console.error("Accept failed:", err); 75 + } finally { 76 + accepting.value = null; 77 + } 78 + }; 79 + 80 + return ( 81 + <div class={ui.section}> 82 + <h2 class={ui.sectionTitle}>Pending Invitations</h2> 83 + <div class={ui.stackSm}> 84 + {data.invitations.map((inv) => ( 85 + <div class={ui.card} key={inv.sphere.id}> 86 + <div class={ui.row}> 87 + <div> 88 + <a href={`/s/${inv.sphere.handle}`}><strong>{inv.sphere.name}</strong></a> 89 + <span class={`${ui.muted} ${ui.inlineTag}`}>{inv.role}</span> 90 + </div> 91 + <button 92 + class={ui.button} 93 + onClick={() => handleAccept(inv)} 94 + disabled={accepting.value === inv.sphere.handle} 95 + > 96 + {accepting.value === inv.sphere.handle ? "Accepting..." : "Accept"} 97 + </button> 98 + </div> 99 + </div> 100 + ))} 101 + </div> 102 + </div> 103 + ); 104 + } 105 + 51 106 export function Dashboard() { 52 107 const { authenticated } = auth.value; 53 108 ··· 63 118 64 119 {authenticated && ( 65 120 <> 121 + <PendingInvitations /> 66 122 <MySpheres /> 67 123 <div class={ui.section}> 68 124 <a href="/spheres/new" class={ui.button}>
+2 -2
packages/app/src/pages/sphere.tsx
··· 208 208 <div class={ui.card} key={m.did}> 209 209 <div class={ui.row}> 210 210 <div> 211 - <strong class={ui.didText}>{m.did}</strong> 211 + <strong class={ui.didText}>{m.handle ? `@${m.handle}` : m.did}</strong> 212 212 <span class={`${ui.muted} ${ui.inlineTag}`}> 213 213 {roleLabels[m.role] ?? m.role} 214 214 </span> ··· 303 303 <div class={ui.stackSm}> 304 304 {Object.entries(perms.data.modules).map(([moduleName, mod]) => ( 305 305 <div key={moduleName}> 306 - <h3 class={ui.subsectionTitle}> 306 + <h3 class={ui.sectionSubheading}> 307 307 {moduleLabels[moduleName]?.label ?? moduleName} 308 308 </h3> 309 309 <div class={ui.stackSm}>
+3 -1
packages/app/src/server.ts
··· 6 6 createSphereRoutes, 7 7 getCurrentSphere, 8 8 getMemberSpheres, 9 + getPendingInvitations, 9 10 sphereContext, 10 11 } from "@exosphere/core/sphere"; 11 12 import { isMultiSphere } from "@exosphere/core/config"; ··· 146 147 // Prefetch page data by calling our own API routes internally 147 148 const pageData: Record<string, unknown> = {}; 148 149 149 - // Dashboard: prefetch user's spheres for the "My Spheres" section 150 + // Dashboard: prefetch user's spheres and pending invitations 150 151 if (isMultiSphere && !sphereHandleFromUrl && authData.did) { 151 152 pageData["my-spheres"] = { spheres: getMemberSpheres(authData.did) }; 153 + pageData["invitations"] = { invitations: getPendingInvitations(authData.did) }; 152 154 } 153 155 154 156 if (sphere) {
+9 -1
packages/app/src/vite-ssr-plugin.ts
··· 116 116 // Prefetch page data from the API server 117 117 const pageData: Record<string, unknown> = {}; 118 118 119 - // Dashboard: prefetch user's spheres for the "My Spheres" section 119 + // Dashboard: prefetch user's spheres and pending invitations 120 120 if (isMultiSphere && !sphereHandleFromUrl && authData.authenticated) { 121 121 try { 122 122 const res = await fetch(`${API_SERVER}/api/spheres?member=true`, { 123 123 headers: { cookie }, 124 124 }); 125 125 if (res.ok) pageData["my-spheres"] = await res.json(); 126 + } catch { 127 + /* prefetch failed — client will fetch */ 128 + } 129 + try { 130 + const res = await fetch(`${API_SERVER}/api/spheres/invitations`, { 131 + headers: { cookie }, 132 + }); 133 + if (res.ok) pageData["invitations"] = await res.json(); 126 134 } catch { 127 135 /* prefetch failed — client will fetch */ 128 136 }
+1
packages/client/package.json
··· 18 18 "./format": "./src/format.ts", 19 19 "./theme-state": "./src/theme-state.ts", 20 20 "./components/collapsible-section": "./src/components/collapsible-section.tsx", 21 + "./components/invitation-banner": "./src/components/invitation-banner.tsx", 21 22 "./components/theme-toggle": "./src/components/theme-toggle.tsx" 22 23 }, 23 24 "peerDependencies": {
+45
packages/client/src/components/invitation-banner.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + import { auth } from "../auth.ts"; 3 + import { sphereState, sphereHandle, refreshSphere } from "../sphere.ts"; 4 + import { apiFetch } from "../api.ts"; 5 + import * as ui from "../ui.css.ts"; 6 + 7 + export function InvitationBanner() { 8 + const { data } = sphereState.value; 9 + const handle = sphereHandle.value; 10 + const { authenticated } = auth.value; 11 + 12 + if (!data || !handle || !authenticated || data.memberStatus !== "invited") return null; 13 + 14 + const accepting = useSignal(false); 15 + const error = useSignal(""); 16 + 17 + const accept = async () => { 18 + accepting.value = true; 19 + error.value = ""; 20 + try { 21 + await apiFetch(`/api/spheres/${encodeURIComponent(handle)}/members/accept`, { 22 + method: "POST", 23 + }); 24 + await refreshSphere(); 25 + } catch (err) { 26 + error.value = err instanceof Error ? err.message : "Failed to accept invitation."; 27 + } finally { 28 + accepting.value = false; 29 + } 30 + }; 31 + 32 + return ( 33 + <div class={ui.container}> 34 + <div class={ui.banner}> 35 + <span class={ui.bannerText}> 36 + You've been invited to join <strong>{data.sphere.name}</strong>. 37 + </span> 38 + <button class={ui.button} onClick={accept} disabled={accepting.value}> 39 + {accepting.value ? "Accepting..." : "Accept invitation"} 40 + </button> 41 + {error.value && <p class={ui.errorText}>{error.value}</p>} 42 + </div> 43 + </div> 44 + ); 45 + }
+25
packages/client/src/ui.css.ts
··· 393 393 color: vars.color.textMuted, 394 394 }); 395 395 396 + export const sectionSubheading = style({ 397 + fontSize: "1.125rem", 398 + fontWeight: 600, 399 + color: vars.color.textMuted, 400 + }); 401 + 396 402 export const collapsibleHeading = style({ 397 403 display: "flex", 398 404 alignItems: "center", ··· 525 531 }); 526 532 527 533 export const flexGrow = style({ 534 + flex: 1, 535 + }); 536 + 537 + // ---- Banners ---- 538 + 539 + export const banner = style({ 540 + display: "flex", 541 + alignItems: "center", 542 + justifyContent: "space-between", 543 + gap: vars.space.md, 544 + backgroundColor: vars.color.primaryLight, 545 + border: `1px solid ${vars.color.primary}`, 546 + borderRadius: vars.radius.md, 547 + paddingBlock: vars.space.md, 548 + paddingInline: vars.space.lg, 549 + fontSize: "0.875rem", 550 + }); 551 + 552 + export const bannerText = style({ 528 553 flex: 1, 529 554 }); 530 555
+16 -2
packages/core/src/generated/lexicon-records.ts
··· 52 52 visibility: string; 53 53 /** Module names enabled for this Sphere. */ 54 54 modules?: string[]; 55 - /** Per-module permission overrides. Key: "module:action", value: minimum role. */ 56 - permissions?: Record<string, string>; 57 55 /** datetime */ 58 56 createdAt: string; 57 + } 58 + 59 + export interface FeatureRequestPermissionsRecord { 60 + /** Minimum role to create feature requests. */ 61 + create?: string; 62 + /** Minimum role to vote on feature requests. */ 63 + vote?: string; 64 + /** Minimum role to comment on feature requests. */ 65 + comment?: string; 66 + /** Minimum role to change feature request status. */ 67 + "change-status"?: string; 68 + /** Minimum role to mark feature requests as duplicate. */ 69 + "mark-duplicate"?: string; 70 + /** Minimum role to hide/unhide content. */ 71 + moderate?: string; 59 72 } 60 73 61 74 export interface SphereMemberRecord { ··· 80 93 "site.exosphere.featureRequestVote": FeatureRequestVoteRecord; 81 94 "site.exosphere.moderation": ModerationRecord; 82 95 "site.exosphere.sphere": SphereRecord; 96 + "site.exosphere.featureRequestPermissions": FeatureRequestPermissionsRecord; 83 97 "site.exosphere.sphereMember": SphereMemberRecord; 84 98 "site.exosphere.sphereMemberApproval": SphereMemberApprovalRecord; 85 99 }
+6 -1
packages/core/src/permissions/index.ts
··· 1 1 export { ROLE_LEVELS, ROLES, hasMinimumRole } from "./roles.ts"; 2 2 export type { Role, MemberRole } from "./roles.ts"; 3 - export { registerModulePermissions, getAllModulePermissions } from "./registry.ts"; 3 + export { 4 + registerModulePermissions, 5 + getAllModulePermissions, 6 + getModulePermissionsCollection, 7 + getAllPermissionsCollections, 8 + } from "./registry.ts"; 4 9 export type { ModulePermission } from "./registry.ts"; 5 10 export { getRequiredRole, checkPermission, computeUserPermissions } from "./check.ts"; 6 11 export { requirePermission } from "./middleware.ts";
+15
packages/core/src/permissions/registry.ts
··· 8 8 } 9 9 10 10 const modulePermissions = new Map<string, Record<string, ModulePermission>>(); 11 + const moduleCollections = new Map<string, string>(); 11 12 12 13 /** Register a module's permission declarations. Called at server startup. */ 13 14 export function registerModulePermissions( 14 15 moduleName: string, 15 16 perms: Record<string, ModulePermission>, 17 + permissionsCollection?: string, 16 18 ): void { 17 19 modulePermissions.set(moduleName, perms); 20 + if (permissionsCollection) { 21 + moduleCollections.set(moduleName, permissionsCollection); 22 + } 18 23 } 19 24 20 25 /** Get the default role for a specific module action. */ ··· 26 31 export function getAllModulePermissions(): ReadonlyMap<string, Record<string, ModulePermission>> { 27 32 return modulePermissions; 28 33 } 34 + 35 + /** Get the PDS collection ID for a module's permissions record. */ 36 + export function getModulePermissionsCollection(moduleName: string): string | undefined { 37 + return moduleCollections.get(moduleName); 38 + } 39 + 40 + /** Get all module name → PDS collection mappings. */ 41 + export function getAllPermissionsCollections(): ReadonlyMap<string, string> { 42 + return moduleCollections; 43 + }
+9 -2
packages/core/src/sphere/api/members.ts
··· 13 13 activateMember, 14 14 } from "../operations.ts"; 15 15 import { findSphere } from "./helpers.ts"; 16 + import { resolveDidHandles } from "../../identity/index.ts"; 16 17 17 18 const SPHERE_COLLECTION = "site.exosphere.sphere"; 18 19 const MEMBER_COLLECTION = "site.exosphere.sphereMember"; ··· 21 22 const app = new Hono<AuthEnv>(); 22 23 23 24 // List members of a sphere 24 - app.get("/:handle/members", requireAuth, (c) => { 25 + app.get("/:handle/members", requireAuth, async (c) => { 25 26 const sphere = findSphere(c.req.param("handle")); 26 27 if (!sphere) { 27 28 return c.json({ error: "Sphere not found" }, 404); ··· 46 47 .orderBy(sphereMembers.createdAt) 47 48 .all(); 48 49 49 - return c.json({ members: rows }); 50 + const handleMap = await resolveDidHandles(rows.map((r) => r.did)); 51 + const members = rows.map((r) => ({ 52 + ...r, 53 + handle: handleMap.get(r.did) ?? null, 54 + })); 55 + 56 + return c.json({ members }); 50 57 }); 51 58 52 59 // Invite a member (admin/owner only)
+30 -15
packages/core/src/sphere/api/permissions.ts
··· 4 4 import { getDb } from "../../db/index.ts"; 5 5 import { spherePermissions } from "../../db/schema/index.ts"; 6 6 import { requireAuth, type AuthEnv } from "../../auth/index.ts"; 7 - import { putPdsRecord } from "../../pds.ts"; 7 + import { putPdsRecord, deletePdsRecord } from "../../pds.ts"; 8 + import type { PdsRecordMap } from "../../generated/lexicon-records.ts"; 8 9 import { 9 10 getAllModulePermissions, 11 + getModulePermissionsCollection, 10 12 getRequiredRole, 11 13 type Role, 12 14 } from "../../permissions/index.ts"; 13 15 import { getActiveMemberRole, isAdminOrOwner } from "../operations.ts"; 14 16 import { findSphere, getEnabledModules } from "./helpers.ts"; 15 - 16 - const SPHERE_COLLECTION = "site.exosphere.sphere" as const; 17 17 18 18 const updatePermissionsSchema = z.object({ 19 19 overrides: z.record(z.string(), z.enum(["owner", "admin", "member", "authenticated"])), ··· 136 136 } 137 137 }); 138 138 139 - // Build the permissions map for PDS sync (only overrides) 139 + // Sync per-module permission records to PDS 140 + // Group all overrides by module 140 141 const allOverrides = db 141 142 .select({ actionKey: spherePermissions.actionKey, minRole: spherePermissions.minRole }) 142 143 .from(spherePermissions) 143 144 .where(eq(spherePermissions.sphereId, sphere.id)) 144 145 .all(); 145 146 146 - const permissionsMap: Record<string, string> = {}; 147 + const byModule = new Map<string, Record<string, string>>(); 147 148 for (const row of allOverrides) { 148 - permissionsMap[row.actionKey] = row.minRole; 149 + const sepIdx = row.actionKey.indexOf(":"); 150 + if (sepIdx === -1) continue; 151 + const moduleName = row.actionKey.slice(0, sepIdx); 152 + const action = row.actionKey.slice(sepIdx + 1); 153 + if (!byModule.has(moduleName)) byModule.set(moduleName, {}); 154 + byModule.get(moduleName)![action] = row.minRole; 149 155 } 150 156 151 - // Sync sphere record to PDS 157 + // Write (or delete) a PDS record for each module that has a permissions collection 152 158 const enabledModules = getEnabledModules(sphere.id).map((m) => m.moduleName); 153 - await putPdsRecord(c.var.session, SPHERE_COLLECTION, "self", { 154 - name: sphere.name, 155 - description: sphere.description ?? undefined, 156 - visibility: sphere.visibility, 157 - modules: enabledModules, 158 - permissions: Object.keys(permissionsMap).length > 0 ? permissionsMap : undefined, 159 - createdAt: sphere.createdAt, 160 - }); 159 + for (const moduleName of enabledModules) { 160 + const collection = getModulePermissionsCollection(moduleName); 161 + if (!collection) continue; 162 + 163 + const moduleOverrides = byModule.get(moduleName); 164 + if (moduleOverrides && Object.keys(moduleOverrides).length > 0) { 165 + await putPdsRecord( 166 + c.var.session, 167 + collection as "site.exosphere.featureRequestPermissions", 168 + "self", 169 + moduleOverrides as PdsRecordMap["site.exosphere.featureRequestPermissions"], 170 + ); 171 + } else { 172 + // No overrides — delete the record so PDS only holds actual overrides 173 + await deletePdsRecord(c.var.session, collection, "self"); 174 + } 175 + } 161 176 162 177 return c.json({ ok: true }); 163 178 });
+39 -4
packages/core/src/sphere/api/spheres.ts
··· 6 6 import { requireAuth, optionalAuth, type AuthEnv } from "../../auth/index.ts"; 7 7 import { putPdsRecord, generateRkey } from "../../pds.ts"; 8 8 import { createSphereSchema, updateSphereSchema } from "../schemas.ts"; 9 - import { getActiveMemberRole } from "../operations.ts"; 9 + import { getActiveMemberRole, getMemberStatus } from "../operations.ts"; 10 10 import { computeUserPermissions } from "../../permissions/check.ts"; 11 11 import { findSphere, getEnabledModules, formatModules } from "./helpers.ts"; 12 12 ··· 45 45 .from(sphereMembers) 46 46 .where(and(eq(sphereMembers.sphereId, sphere.id), eq(sphereMembers.status, "active"))) 47 47 .get(); 48 - const role = did ? getActiveMemberRole(sphere.id, did) : null; 48 + const membership = did ? getMemberStatus(sphere.id, did) : null; 49 + const role = membership?.status === "active" ? membership.role : null; 49 50 const permissions = did ? computeUserPermissions(sphere.id, role, enabledNames) : undefined; 50 51 return { 51 52 sphere, 52 53 modules: formatModules(modules), 53 54 memberCount: memberCount?.count ?? 0, 54 55 role, 56 + memberStatus: membership?.status ?? null, 55 57 permissions, 56 58 }; 57 59 } ··· 165 167 .get(); 166 168 167 169 const did = c.var.did; 168 - const role = did ? getActiveMemberRole(sphere.id, did) : null; 170 + const membership = did ? getMemberStatus(sphere.id, did) : null; 171 + const role = membership?.status === "active" ? membership.role : null; 169 172 const permissions = did ? computeUserPermissions(sphere.id, role, enabledNames) : undefined; 170 173 171 174 return c.json({ ··· 173 176 modules: formatModules(modules), 174 177 memberCount: memberCount?.count ?? 0, 175 178 role, 179 + memberStatus: membership?.status ?? null, 176 180 permissions, 177 181 }); 178 182 }); 179 183 184 + /** Return pending invitations for a given DID. */ 185 + export function getPendingInvitations(did: string) { 186 + const db = getDb(); 187 + const rows = db 188 + .select({ 189 + sphere: spheres, 190 + role: sphereMembers.role, 191 + createdAt: sphereMembers.createdAt, 192 + }) 193 + .from(sphereMembers) 194 + .innerJoin(spheres, eq(spheres.id, sphereMembers.sphereId)) 195 + .where( 196 + and(eq(sphereMembers.did, did), eq(sphereMembers.status, "invited")), 197 + ) 198 + .orderBy(sphereMembers.createdAt) 199 + .all(); 200 + 201 + return rows.map((r) => ({ 202 + sphere: { id: r.sphere.id, handle: r.sphere.handle, name: r.sphere.name }, 203 + role: r.role, 204 + createdAt: r.createdAt, 205 + })); 206 + } 207 + 208 + // List pending invitations for the current user 209 + app.get("/invitations", requireAuth, (c) => { 210 + return c.json({ invitations: getPendingInvitations(c.var.did) }); 211 + }); 212 + 180 213 // Get sphere by handle 181 214 app.get("/:handle", optionalAuth, (c) => { 182 215 const sphere = findSphere(c.req.param("handle")); ··· 193 226 .get(); 194 227 195 228 const did = c.var.did; 196 - const role = did ? getActiveMemberRole(sphere.id, did) : null; 229 + const membership = did ? getMemberStatus(sphere.id, did) : null; 230 + const role = membership?.status === "active" ? membership.role : null; 197 231 const permissions = did ? computeUserPermissions(sphere.id, role, enabledNames) : undefined; 198 232 199 233 return c.json({ ··· 201 235 modules: formatModules(modules), 202 236 memberCount: memberCount?.count ?? 0, 203 237 role, 238 + memberStatus: membership?.status ?? null, 204 239 permissions, 205 240 }); 206 241 });
+2 -2
packages/core/src/sphere/index.ts
··· 1 - export { createSphereRoutes, getCurrentSphere, getMemberSpheres } from "./routes.ts"; 2 - export { coreIndexer } from "./indexer.ts"; 1 + export { createSphereRoutes, getCurrentSphere, getMemberSpheres, getPendingInvitations } from "./routes.ts"; 2 + export { createCoreIndexer } from "./indexer.ts"; 3 3 export { 4 4 getActiveMemberRole, 5 5 isAdminOrOwner,
+68 -36
packages/core/src/sphere/indexer.ts
··· 7 7 deleteSphereMemberApproval, 8 8 deleteSphereMember, 9 9 indexModerationAction, 10 + syncModulePermissionsFromRecord, 11 + deleteModulePermissions, 10 12 } from "./operations.ts"; 13 + import { getAllPermissionsCollections } from "../permissions/index.ts"; 11 14 12 15 const SPHERE_COLLECTION = "site.exosphere.sphere"; 13 16 const MEMBER_COLLECTION = "site.exosphere.sphereMember"; 14 17 const APPROVAL_COLLECTION = "site.exosphere.sphereMemberApproval"; 15 18 const MODERATION_COLLECTION = "site.exosphere.moderation"; 16 19 17 - export const coreIndexer: ModuleIndexer = { 18 - collections: [SPHERE_COLLECTION, MEMBER_COLLECTION, APPROVAL_COLLECTION, MODERATION_COLLECTION], 20 + /** Build the core indexer dynamically — includes permission collections from registered modules. */ 21 + export function createCoreIndexer(): ModuleIndexer { 22 + const permCollections = getAllPermissionsCollections(); 23 + // Reverse lookup: collection → module name 24 + const collectionToModule = new Map<string, string>(); 25 + for (const [moduleName, collection] of permCollections) { 26 + collectionToModule.set(collection, moduleName); 27 + } 19 28 20 - handleCreateOrUpdate(event: JetstreamCommitEvent) { 21 - const { did, commit } = event; 22 - const { collection, rkey, record } = commit; 23 - if (!record) return; 29 + const baseCollections = [SPHERE_COLLECTION, MEMBER_COLLECTION, APPROVAL_COLLECTION, MODERATION_COLLECTION]; 30 + const allCollections = [...baseCollections, ...collectionToModule.keys()]; 24 31 25 - const pdsUri = buildAtUri(did, collection, rkey); 32 + return { 33 + collections: allCollections, 26 34 27 - switch (collection) { 28 - case SPHERE_COLLECTION: 29 - upsertSphereFromRecord({ did, rkey, record, pdsUri }); 30 - break; 31 - case APPROVAL_COLLECTION: 32 - indexSphereMemberApproval({ did, rkey, record, pdsUri }); 33 - break; 34 - case MEMBER_COLLECTION: 35 - indexSphereMember({ did, rkey, record, pdsUri }); 36 - break; 37 - case MODERATION_COLLECTION: 38 - indexModerationAction(did, record); 39 - break; 40 - } 41 - }, 35 + handleCreateOrUpdate(event: JetstreamCommitEvent) { 36 + const { did, commit } = event; 37 + const { collection, rkey, record } = commit; 38 + if (!record) return; 42 39 43 - handleDelete(event: JetstreamCommitEvent) { 44 - const { did, commit } = event; 45 - const { collection, rkey } = commit; 46 - const pdsUri = buildAtUri(did, collection, rkey); 40 + // Check if this is a module permissions collection 41 + const moduleName = collectionToModule.get(collection); 42 + if (moduleName) { 43 + syncModulePermissionsFromRecord(did, moduleName, record); 44 + return; 45 + } 47 46 48 - switch (collection) { 49 - case APPROVAL_COLLECTION: 50 - deleteSphereMemberApproval(did, pdsUri); 51 - break; 52 - case MEMBER_COLLECTION: 53 - deleteSphereMember(did, pdsUri); 54 - break; 55 - } 56 - }, 57 - }; 47 + const pdsUri = buildAtUri(did, collection, rkey); 48 + 49 + switch (collection) { 50 + case SPHERE_COLLECTION: 51 + upsertSphereFromRecord({ did, rkey, record, pdsUri }); 52 + break; 53 + case APPROVAL_COLLECTION: 54 + indexSphereMemberApproval({ did, rkey, record, pdsUri }); 55 + break; 56 + case MEMBER_COLLECTION: 57 + indexSphereMember({ did, rkey, record, pdsUri }); 58 + break; 59 + case MODERATION_COLLECTION: 60 + indexModerationAction(did, record); 61 + break; 62 + } 63 + }, 64 + 65 + handleDelete(event: JetstreamCommitEvent) { 66 + const { did, commit } = event; 67 + const { collection, rkey } = commit; 68 + 69 + // Check if this is a module permissions collection being deleted 70 + const moduleName = collectionToModule.get(collection); 71 + if (moduleName) { 72 + deleteModulePermissions(did, moduleName); 73 + return; 74 + } 75 + 76 + const pdsUri = buildAtUri(did, collection, rkey); 77 + 78 + switch (collection) { 79 + case APPROVAL_COLLECTION: 80 + deleteSphereMemberApproval(did, pdsUri); 81 + break; 82 + case MEMBER_COLLECTION: 83 + deleteSphereMember(did, pdsUri); 84 + break; 85 + } 86 + }, 87 + }; 88 + } 89 +
+77 -22
packages/core/src/sphere/operations.ts
··· 1 - import { eq, and } from "drizzle-orm"; 1 + import { eq, and, like } from "drizzle-orm"; 2 2 import { getDb } from "../db/index.ts"; 3 3 import { spheres, sphereMembers, spherePermissions } from "../db/schema/index.ts"; 4 4 import { parseAtUri } from "../indexer/uri.ts"; 5 - import { ROLES, type Role } from "../permissions/roles.ts"; 5 + import { ROLES } from "../permissions/roles.ts"; 6 6 7 7 // ---- Membership utilities ---- 8 8 ··· 23 23 24 24 export function isAdminOrOwner(role: typeof sphereMembers.$inferSelect.role | null): boolean { 25 25 return role === "owner" || role === "admin"; 26 + } 27 + 28 + /** Get the membership status for a DID in a sphere (any status, not just active). */ 29 + export function getMemberStatus(sphereId: string, did: string) { 30 + const row = getDb() 31 + .select({ role: sphereMembers.role, status: sphereMembers.status }) 32 + .from(sphereMembers) 33 + .where( 34 + and(eq(sphereMembers.sphereId, sphereId), eq(sphereMembers.did, did)), 35 + ) 36 + .get(); 37 + return row ?? null; 26 38 } 27 39 28 40 const VALID_ROLES = ["owner", "admin", "member"] as const; ··· 74 86 set.updatedAt = new Date().toISOString(); 75 87 76 88 db.update(spheres).set(set).where(eq(spheres.id, existing.id)).run(); 77 - 78 - // Sync permission overrides from PDS 79 - if (record.permissions && typeof record.permissions === "object") { 80 - const perms = record.permissions as Record<string, string>; 81 - const validRoles = new Set<string>(ROLES); 82 - 83 - // Clear existing overrides and replace 84 - db.delete(spherePermissions) 85 - .where(eq(spherePermissions.sphereId, existing.id)) 86 - .run(); 87 - 88 - for (const [actionKey, minRole] of Object.entries(perms)) { 89 - if (typeof actionKey === "string" && actionKey.includes(":") && validRoles.has(minRole)) { 90 - db.insert(spherePermissions) 91 - .values({ sphereId: existing.id, actionKey, minRole }) 92 - .onConflictDoNothing() 93 - .run(); 94 - } 95 - } 96 - } 97 89 } else { 98 90 // Ignore sphere records for DIDs not on this instance — 99 91 // spheres are created locally, not via Jetstream from other instances. ··· 214 206 .update(sphereMembers) 215 207 .set({ pdsUri: null }) 216 208 .where(and(eq(sphereMembers.did, did), eq(sphereMembers.pdsUri, pdsUri))) 209 + .run(); 210 + } 211 + 212 + // ---- Per-module permission sync from PDS ---- 213 + 214 + function findSphereByOwner(did: string): { id: string } | undefined { 215 + return getDb() 216 + .select({ id: spheres.id }) 217 + .from(spheres) 218 + .where(eq(spheres.ownerDid, did)) 219 + .get(); 220 + } 221 + 222 + /** Sync a module's permission overrides from a PDS record into the local DB. */ 223 + export function syncModulePermissionsFromRecord( 224 + did: string, 225 + moduleName: string, 226 + record: Record<string, unknown>, 227 + ): void { 228 + const sphere = findSphereByOwner(did); 229 + if (!sphere) return; 230 + 231 + const db = getDb(); 232 + 233 + const validRoles = new Set<string>(ROLES); 234 + const prefix = `${moduleName}:`; 235 + 236 + db.transaction((tx) => { 237 + // Clear existing overrides for this module 238 + tx.delete(spherePermissions) 239 + .where( 240 + and( 241 + eq(spherePermissions.sphereId, sphere.id), 242 + like(spherePermissions.actionKey, `${prefix}%`), 243 + ), 244 + ) 245 + .run(); 246 + 247 + // Insert new overrides 248 + for (const [action, minRole] of Object.entries(record)) { 249 + if (action === "$type") continue; 250 + if (typeof minRole !== "string" || !validRoles.has(minRole)) continue; 251 + 252 + tx.insert(spherePermissions) 253 + .values({ sphereId: sphere.id, actionKey: `${prefix}${action}`, minRole }) 254 + .onConflictDoNothing() 255 + .run(); 256 + } 257 + }); 258 + } 259 + 260 + /** Delete all permission overrides for a module when its PDS record is deleted. */ 261 + export function deleteModulePermissions(did: string, moduleName: string): void { 262 + const sphere = findSphereByOwner(did); 263 + if (!sphere) return; 264 + 265 + getDb().delete(spherePermissions) 266 + .where( 267 + and( 268 + eq(spherePermissions.sphereId, sphere.id), 269 + like(spherePermissions.actionKey, `${moduleName}:%`), 270 + ), 271 + ) 217 272 .run(); 218 273 } 219 274
+1 -1
packages/core/src/sphere/routes.ts
··· 5 5 import { membersApi } from "./api/members.ts"; 6 6 import { permissionsApi } from "./api/permissions.ts"; 7 7 8 - export { getCurrentSphere, getMemberSpheres } from "./api/spheres.ts"; 8 + export { getCurrentSphere, getMemberSpheres, getPendingInvitations } from "./api/spheres.ts"; 9 9 10 10 export function createSphereRoutes(availableModules: string[]) { 11 11 const app = new Hono<AuthEnv>();
+4
packages/core/src/types/index.ts
··· 45 45 indexer?: ModuleIndexer; 46 46 /** Named actions and their default minimum role, displayed in the admin panel */ 47 47 permissions?: Record<string, ModulePermission>; 48 + /** AT Protocol collection ID for this module's permission overrides record (e.g. "site.exosphere.featureRequestPermissions") */ 49 + permissionsCollection?: string; 48 50 } 49 51 50 52 /** Formatted module info as returned by the API (subset of SphereModule DB row). */ ··· 59 61 modules: SphereModuleInfo[]; 60 62 memberCount: number; 61 63 role: SphereMember["role"] | null; 64 + /** Membership status — null if not a member, "invited" if pending, "active" if accepted. */ 65 + memberStatus: SphereMember["status"] | null; 62 66 /** Pre-computed permission map for the current user: "module:action" → boolean. */ 63 67 permissions?: Record<string, boolean>; 64 68 }
+2 -1
packages/feature-requests/src/index.ts
··· 11 11 vote: { label: "Vote on feature requests", defaultRole: "authenticated" }, 12 12 comment: { label: "Comment on feature requests", defaultRole: "authenticated" }, 13 13 "change-status": { label: "Change status", defaultRole: "admin" }, 14 - "mark-duplicate": { label: "Mark as duplicate", defaultRole: "authenticated" }, 14 + "mark-duplicate": { label: "Mark as duplicate", defaultRole: "admin" }, 15 15 moderate: { label: "Hide/unhide content", defaultRole: "admin" }, 16 16 }, 17 + permissionsCollection: "site.exosphere.featureRequestPermissions", 17 18 };
+5 -3
packages/indexer/src/modules.ts
··· 1 1 import type { ExosphereModule } from "@exosphere/core/types"; 2 - import { coreIndexer } from "@exosphere/core/sphere"; 2 + import { createCoreIndexer } from "@exosphere/core/sphere"; 3 3 import { registerModulePermissions } from "@exosphere/core/permissions"; 4 4 // import { feedsModule } from "@exosphere/feeds"; 5 5 import { featureRequestsModule } from "@exosphere/feature-requests"; 6 6 7 7 export const modules: ExosphereModule[] = [featureRequestsModule]; 8 - export { coreIndexer }; 9 8 10 9 // Register module permissions so the indexer can check them 11 10 for (const mod of modules) { 12 11 if (mod.permissions) { 13 - registerModulePermissions(mod.name, mod.permissions); 12 + registerModulePermissions(mod.name, mod.permissions, mod.permissionsCollection); 14 13 } 15 14 } 15 + 16 + // Create core indexer AFTER registration so it picks up permission collections 17 + export const coreIndexer = createCoreIndexer();