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.

refactor: review

Hugo b6efee12 bc811ff7

+118 -116
+2
packages/core/src/sphere/index.ts
··· 10 10 registerModerationHandler, 11 11 registerLabelHandler, 12 12 findSphereByAtUri, 13 + findSphereForAccess, 13 14 } from "./operations.ts"; 14 15 export type { ModerationHandler, LabelHandler } from "./operations.ts"; 15 16 export { sphereContext } from "./middleware.ts"; ··· 20 21 resolveLabelIdsByName, 21 22 getOrCreateLabelRkey, 22 23 upsertLabelRkey, 24 + writeLabelPdsRecord, 23 25 } from "./label-operations.ts"; 24 26 export type { LabelInfo } from "./label-operations.ts"; 25 27 export { setEntityLabelsSchema } from "./schemas.ts";
+23 -1
packages/core/src/sphere/label-operations.ts
··· 2 2 import { getDb } from "../db/index.ts"; 3 3 import { sphereLabels, entityLabels, labelPdsRecords } from "../db/schema/index.ts"; 4 4 import type { SphereLabel, EntityType } from "../db/schema/index.ts"; 5 - import { generateRkey } from "../pds.ts"; 5 + import { generateRkey, putPdsRecord } from "../pds.ts"; 6 + import type { OAuthSession } from "@atproto/oauth-client-node"; 6 7 7 8 export function getLabelsForSphere(sphereId: string): SphereLabel[] { 8 9 return getDb() ··· 209 210 } 210 211 }); 211 212 } 213 + 214 + const LABEL_COLLECTION = "site.exosphere.sphere.label"; 215 + 216 + /** Write (or update) the label PDS record for an entity. 217 + * Returns the ISO timestamp used, or null if the write was skipped/failed. */ 218 + export async function writeLabelPdsRecord( 219 + session: OAuthSession, 220 + entityId: string, 221 + entityType: EntityType, 222 + subjectUri: string, 223 + labels: LabelInfo[], 224 + ): Promise<string | null> { 225 + const now = new Date().toISOString(); 226 + const labelRkey = getOrCreateLabelRkey(entityId, entityType, session.did); 227 + const uri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, { 228 + subject: subjectUri, 229 + labels: labels.map((l) => l.name), 230 + updatedAt: now, 231 + }); 232 + return uri ? now : null; 233 + }
+26 -5
packages/core/src/sphere/operations.ts
··· 59 59 ); 60 60 } 61 61 62 + /** Look up a sphere by owner DID and check if a user has a given module permission. */ 63 + export function findSphereForAccess( 64 + sphereOwnerDid: string, 65 + did: string, 66 + moduleName: string, 67 + action: string, 68 + ): { allowed: boolean; sphereId: string | null } { 69 + const db = getDb(); 70 + const sphere = db 71 + .select({ id: spheres.id }) 72 + .from(spheres) 73 + .where(eq(spheres.ownerDid, sphereOwnerDid)) 74 + .get(); 75 + if (!sphere) return { allowed: false, sphereId: null }; 76 + const role = getActiveMemberRole(sphere.id, did); 77 + const allowed = checkPermission(sphere.id, moduleName, action, role); 78 + return { allowed, sphereId: sphere.id }; 79 + } 80 + 62 81 interface UpsertSphereParams { 63 82 did: string; 64 83 rkey: string; ··· 269 288 // ---- Moderation ---- 270 289 271 290 /** Callback that tries to hide content by its pdsUri. Returns true if it handled the subject. */ 272 - export type ModerationHandler = (subjectUri: string, moderatorDid: string) => boolean; 291 + export type ModerationHandler = ( 292 + subjectUri: string, 293 + moderatorDid: string, 294 + sphereId: string, 295 + ) => boolean; 273 296 274 297 const moderationHandlers: ModerationHandler[] = []; 275 298 ··· 287 310 const sphere = findSphereByAtUri(sphereUri); 288 311 if (!sphere) return; 289 312 290 - const role = getActiveMemberRole(sphere.id, did); 291 - if (!checkPermission(sphere.id, "feature-requests", "moderate", role)) return; 292 - 313 + // Each handler checks its own module permission 293 314 for (const handler of moderationHandlers) { 294 - if (handler(subjectUri, did)) return; 315 + if (handler(subjectUri, did, sphere.id)) return; 295 316 } 296 317 } 297 318
+8 -3
packages/feature-requests/src/__tests__/db-operations.test.ts
··· 4 4 5 5 // Use the shared test-db helper from core (resolves via workspace) 6 6 import { createTestDb, seedSphere } from "../../../core/src/__tests__/helpers/test-db.ts"; 7 + import { sphereMembers } from "@exosphere/core/db/schema"; 7 8 8 9 let db: BetterSQLite3Database; 9 10 ··· 60 61 beforeEach(() => { 61 62 db = createTestDb(); 62 63 seedSphere(db, { id: SPHERE_ID, handle: "test.bsky.social", ownerDid: AUTHOR_DID }); 64 + // Add MOD_DID as admin so moderation permission checks pass 65 + db.insert(sphereMembers) 66 + .values({ sphereId: SPHERE_ID, did: MOD_DID, role: "admin", status: "active" }) 67 + .run(); 63 68 }); 64 69 65 70 // ---- insertFeatureRequest ---- ··· 409 414 const pdsUri = "at://did:plc:author1/com.exosphere.featureRequest/fr-1"; 410 415 seedFR({ id: "fr-1", pdsUri }); 411 416 412 - const handled = handleFeatureRequestModeration(pdsUri, MOD_DID); 417 + const handled = handleFeatureRequestModeration(pdsUri, MOD_DID, SPHERE_ID); 413 418 expect(handled).toBe(true); 414 419 415 420 const fr = db.select().from(featureRequests).where(eq(featureRequests.id, "fr-1")).get(); ··· 427 432 pdsUri: commentPdsUri, 428 433 }); 429 434 430 - const handled = handleFeatureRequestModeration(commentPdsUri, MOD_DID); 435 + const handled = handleFeatureRequestModeration(commentPdsUri, MOD_DID, SPHERE_ID); 431 436 expect(handled).toBe(true); 432 437 433 438 const comment = db ··· 439 444 }); 440 445 441 446 it("returns false when pdsUri matches nothing", () => { 442 - const handled = handleFeatureRequestModeration("at://unknown/col/rkey", MOD_DID); 447 + const handled = handleFeatureRequestModeration("at://unknown/col/rkey", MOD_DID, SPHERE_ID); 443 448 expect(handled).toBe(false); 444 449 }); 445 450 });
+7 -29
packages/feature-requests/src/api/requests.ts
··· 21 21 getLabelsForEntities, 22 22 setEntityLabels, 23 23 setEntityLabelsSchema, 24 - getOrCreateLabelRkey, 24 + writeLabelPdsRecord, 25 25 } from "@exosphere/core/sphere"; 26 26 27 27 const COLLECTION = "site.exosphere.featureRequest.entry"; 28 - const LABEL_COLLECTION = "site.exosphere.sphere.label"; 29 28 const MODERATION_COLLECTION = "site.exosphere.moderation"; 30 29 31 30 const app = new Hono<AuthEnv & SphereEnv>(); ··· 199 198 ? (getLabelsForEntities([id], "feature-request").get(id) ?? []) 200 199 : []; 201 200 202 - // Write label PDS record if labels were set and entity has a PDS URI 203 201 if (pdsUri && labels.length > 0) { 204 202 const session = c.var.session; 205 - const now = new Date().toISOString(); 206 - const labelRkey = getOrCreateLabelRkey(id, "feature-request", did); 207 - const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, { 208 - subject: pdsUri, 209 - labels: labels.map((l) => l.name), 210 - updatedAt: now, 211 - }); 212 - if (labelPdsUri) { 213 - const db = getDb(); 214 - db.update(featureRequests) 215 - .set({ labelUpdatedAt: now }) 216 - .where(eq(featureRequests.id, id)) 217 - .run(); 203 + const labelUpdatedAt = await writeLabelPdsRecord(session, id, "feature-request", pdsUri, labels); 204 + if (labelUpdatedAt) { 205 + getDb().update(featureRequests).set({ labelUpdatedAt }).where(eq(featureRequests.id, id)).run(); 218 206 } 219 207 } 220 208 ··· 444 432 setEntityLabels(sphereId, id, "feature-request", parsed.data.labelIds); 445 433 const labels = getLabelsForEntities([id], "feature-request").get(id) ?? []; 446 434 447 - // Write label PDS record (reuse existing rkey if this user already wrote one) 448 435 if (c.var.sphereVisibility === "public" && existing.pdsUri) { 449 436 const session = c.var.session; 450 - const now = new Date().toISOString(); 451 - const labelRkey = getOrCreateLabelRkey(id, "feature-request", did); 452 - const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, { 453 - subject: existing.pdsUri, 454 - labels: labels.map((l) => l.name), 455 - updatedAt: now, 456 - }); 457 - if (labelPdsUri) { 458 - db.update(featureRequests) 459 - .set({ labelUpdatedAt: now }) 460 - .where(eq(featureRequests.id, id)) 461 - .run(); 437 + const labelUpdatedAt = await writeLabelPdsRecord(session, id, "feature-request", existing.pdsUri, labels); 438 + if (labelUpdatedAt) { 439 + db.update(featureRequests).set({ labelUpdatedAt }).where(eq(featureRequests.id, id)).run(); 462 440 } 463 441 } 464 442
+10 -1
packages/feature-requests/src/db/operations.ts
··· 4 4 import { nextEntryNumber } from "@exosphere/core/db/entry-number"; 5 5 import { tidToDate } from "@exosphere/core/pds"; 6 6 import type { ModerationHandler } from "@exosphere/core/sphere"; 7 + import { getActiveMemberRole } from "@exosphere/core/sphere"; 8 + import { checkPermission } from "@exosphere/core/permissions"; 7 9 import { 8 10 featureRequests, 9 11 featureRequestVotes, ··· 229 231 } 230 232 231 233 /** Moderation handler for feature requests and comments. Returns true if it handled the subject. */ 232 - export const handleFeatureRequestModeration: ModerationHandler = (subjectUri, moderatorDid) => { 234 + export const handleFeatureRequestModeration: ModerationHandler = ( 235 + subjectUri, 236 + moderatorDid, 237 + sphereId, 238 + ) => { 239 + const role = getActiveMemberRole(sphereId, moderatorDid); 240 + if (!checkPermission(sphereId, "feature-requests", "moderate", role)) return false; 241 + 233 242 const db = getDb(); 234 243 235 244 const fr = db
+2 -18
packages/feature-requests/src/indexer.ts
··· 4 4 registerModerationHandler, 5 5 registerLabelHandler, 6 6 getActiveMemberRole, 7 + findSphereForAccess, 7 8 setEntityLabels, 8 9 resolveLabelIdsByName, 9 10 upsertLabelRkey, ··· 11 12 import { checkPermission } from "@exosphere/core/permissions"; 12 13 import { getDb } from "@exosphere/core/db"; 13 14 import { eq, and } from "@exosphere/core/db/drizzle"; 14 - import { spheres } from "@exosphere/core/db/schema"; 15 15 import { featureRequests, featureRequestComments } from "./db/schema.ts"; 16 16 import { statuses } from "./schemas/feature-request.ts"; 17 17 import type { Status } from "./schemas/feature-request.ts"; ··· 75 75 return true; 76 76 }); 77 77 78 - function findSphereForAccess( 79 - sphereOwnerDid: string, 80 - did: string, 81 - action: string, 82 - ): { allowed: boolean; sphereId: string | null } { 83 - const db = getDb(); 84 - const sphere = db 85 - .select({ id: spheres.id }) 86 - .from(spheres) 87 - .where(eq(spheres.ownerDid, sphereOwnerDid)) 88 - .get(); 89 - if (!sphere) return { allowed: false, sphereId: null }; 90 - const role = getActiveMemberRole(sphere.id, did); 91 - const allowed = checkPermission(sphere.id, MODULE_NAME, action, role); 92 - return { allowed, sphereId: sphere.id }; 93 - } 94 78 95 79 export const featureRequestsIndexer: ModuleIndexer = { 96 80 collections: [ ··· 112 96 const subject = record.subject as string; 113 97 if (!subject || !subject.startsWith("did:")) return; 114 98 115 - const access = findSphereForAccess(subject, did, "create"); 99 + const access = findSphereForAccess(subject, did, MODULE_NAME, "create"); 116 100 if (!access.allowed || !access.sphereId) return; 117 101 const sphereId = access.sphereId; 118 102
+8 -6
packages/feature-requests/src/ui/pages/feature-request.tsx
··· 572 572 const localLabelIds = useSignal<string[] | null>(null); 573 573 const labelSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null); 574 574 575 + const fr = data?.featureRequest; 576 + const isAuthor = currentDid != null && currentDid === fr?.authorDid; 577 + const canEditLabels = canChangeStatus.value || isAuthor; 578 + 575 579 useEffect(() => { 576 - if (canChangeStatus.value) { 580 + if (canEditLabels) { 577 581 getLabels() 578 582 .then((res) => (availableLabels.value = res.labels)) 579 583 .catch(() => {}); 580 584 } 581 - }, [canChangeStatus.value]); 582 - 583 - const fr = data?.featureRequest; 585 + }, [canEditLabels]); 584 586 585 587 const handleDelete = async () => { 586 588 if (!fr) return; ··· 652 654 try { 653 655 await updateFeatureRequestLabels(fr.id, localLabelIds.value!); 654 656 } catch { 655 - // Refetch to get the true server state 657 + refetch(); 656 658 } 657 659 }, 300); 658 660 }; ··· 725 727 ) : undefined 726 728 } 727 729 > 728 - {canChangeStatus.value && availableLabels.value.length > 0 ? ( 730 + {canEditLabels && availableLabels.value.length > 0 ? ( 729 731 <InlineLabelEditor 730 732 labels={availableLabels.value} 731 733 selectedIds={localLabelIds.value ?? fr.labels.map((l) => l.id)}
+8 -3
packages/kanban/src/__tests__/db-operations.test.ts
··· 4 4 5 5 // Use the shared test-db helper from core (resolves via workspace) 6 6 import { createTestDb, seedSphere } from "../../../core/src/__tests__/helpers/test-db.ts"; 7 + import { sphereMembers } from "@exosphere/core/db/schema"; 7 8 8 9 let db: BetterSQLite3Database; 9 10 ··· 55 56 beforeEach(() => { 56 57 db = createTestDb(); 57 58 seedSphere(db, { id: SPHERE_ID, handle: "test.bsky.social", ownerDid: AUTHOR_DID }); 59 + // Add MOD_DID as admin so moderation permission checks pass 60 + db.insert(sphereMembers) 61 + .values({ sphereId: SPHERE_ID, did: MOD_DID, role: "admin", status: "active" }) 62 + .run(); 58 63 }); 59 64 60 65 // ---- insertTask ---- ··· 435 440 const pdsUri = "at://did:plc:author1/site.exosphere.kanban.entry/t-1"; 436 441 seedTask({ id: "t-1", pdsUri }); 437 442 438 - const handled = handleKanbanModeration(pdsUri, MOD_DID); 443 + const handled = handleKanbanModeration(pdsUri, MOD_DID, SPHERE_ID); 439 444 expect(handled).toBe(true); 440 445 441 446 const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); ··· 453 458 pdsUri: commentPdsUri, 454 459 }); 455 460 456 - const handled = handleKanbanModeration(commentPdsUri, MOD_DID); 461 + const handled = handleKanbanModeration(commentPdsUri, MOD_DID, SPHERE_ID); 457 462 expect(handled).toBe(true); 458 463 459 464 const comment = db ··· 465 470 }); 466 471 467 472 it("returns false when pdsUri matches nothing", () => { 468 - const handled = handleKanbanModeration("at://unknown/col/rkey", MOD_DID); 473 + const handled = handleKanbanModeration("at://unknown/col/rkey", MOD_DID, SPHERE_ID); 469 474 expect(handled).toBe(false); 470 475 }); 471 476 });
+7 -23
packages/kanban/src/api/tasks.ts
··· 32 32 getLabelsForEntities, 33 33 setEntityLabels, 34 34 setEntityLabelsSchema, 35 - getOrCreateLabelRkey, 35 + writeLabelPdsRecord, 36 36 } from "@exosphere/core/sphere"; 37 37 38 38 const COLLECTION = "site.exosphere.kanban.entry"; 39 - const LABEL_COLLECTION = "site.exosphere.sphere.label"; 40 39 const STATUS_COLLECTION = "site.exosphere.kanban.status"; 41 40 const MODERATION_COLLECTION = "site.exosphere.moderation"; 42 41 ··· 218 217 219 218 const labels = labelIds?.length ? (getLabelsForEntities([id], "kanban-task").get(id) ?? []) : []; 220 219 221 - // Write label PDS record if labels were set and entity has a PDS URI 222 220 if (pdsUri && labels.length > 0) { 223 221 const session = c.var.session; 224 - const now = new Date().toISOString(); 225 - const labelRkey = getOrCreateLabelRkey(id, "kanban-task", did); 226 - const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, { 227 - subject: pdsUri, 228 - labels: labels.map((l) => l.name), 229 - updatedAt: now, 230 - }); 231 - if (labelPdsUri) { 232 - const db = getDb(); 233 - db.update(kanbanTasks).set({ labelUpdatedAt: now }).where(eq(kanbanTasks.id, id)).run(); 222 + const labelUpdatedAt = await writeLabelPdsRecord(session, id, "kanban-task", pdsUri, labels); 223 + if (labelUpdatedAt) { 224 + getDb().update(kanbanTasks).set({ labelUpdatedAt }).where(eq(kanbanTasks.id, id)).run(); 234 225 } 235 226 } 236 227 ··· 619 610 setEntityLabels(sphereId, id, "kanban-task", parsed.data.labelIds); 620 611 const labels = getLabelsForEntities([id], "kanban-task").get(id) ?? []; 621 612 622 - // Write label PDS record (reuse existing rkey if this user already wrote one) 623 613 if (c.var.sphereVisibility === "public" && existing.pdsUri) { 624 614 const session = c.var.session; 625 - const now = new Date().toISOString(); 626 - const labelRkey = getOrCreateLabelRkey(id, "kanban-task", did); 627 - const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, { 628 - subject: existing.pdsUri, 629 - labels: labels.map((l) => l.name), 630 - updatedAt: now, 631 - }); 632 - if (labelPdsUri) { 633 - db.update(kanbanTasks).set({ labelUpdatedAt: now }).where(eq(kanbanTasks.id, id)).run(); 615 + const labelUpdatedAt = await writeLabelPdsRecord(session, id, "kanban-task", existing.pdsUri, labels); 616 + if (labelUpdatedAt) { 617 + db.update(kanbanTasks).set({ labelUpdatedAt }).where(eq(kanbanTasks.id, id)).run(); 634 618 } 635 619 } 636 620
+6 -1
packages/kanban/src/db/operations.ts
··· 4 4 import { nextEntryNumber } from "@exosphere/core/db/entry-number"; 5 5 import { tidToDate, generateRkey } from "@exosphere/core/pds"; 6 6 import type { ModerationHandler } from "@exosphere/core/sphere"; 7 + import { getActiveMemberRole } from "@exosphere/core/sphere"; 8 + import { checkPermission } from "@exosphere/core/permissions"; 7 9 import { 8 10 kanbanColumns, 9 11 kanbanTasks, ··· 351 353 } 352 354 353 355 /** Moderation handler for kanban tasks and comments. Returns true if it handled the subject. */ 354 - export const handleKanbanModeration: ModerationHandler = (subjectUri, moderatorDid) => { 356 + export const handleKanbanModeration: ModerationHandler = (subjectUri, moderatorDid, sphereId) => { 357 + const role = getActiveMemberRole(sphereId, moderatorDid); 358 + if (!checkPermission(sphereId, "kanban", "moderate", role)) return false; 359 + 355 360 const db = getDb(); 356 361 357 362 const task = db
+2 -18
packages/kanban/src/indexer.ts
··· 4 4 registerModerationHandler, 5 5 registerLabelHandler, 6 6 getActiveMemberRole, 7 + findSphereForAccess, 7 8 setEntityLabels, 8 9 resolveLabelIdsByName, 9 10 upsertLabelRkey, ··· 11 12 import { checkPermission } from "@exosphere/core/permissions"; 12 13 import { getDb } from "@exosphere/core/db"; 13 14 import { eq, and } from "@exosphere/core/db/drizzle"; 14 - import { spheres } from "@exosphere/core/db/schema"; 15 15 import { kanbanTasks, kanbanTaskComments } from "./db/schema.ts"; 16 16 import { 17 17 getColumns, ··· 69 69 return true; 70 70 }); 71 71 72 - function findSphereForAccess( 73 - sphereOwnerDid: string, 74 - did: string, 75 - action: string, 76 - ): { allowed: boolean; sphereId: string | null } { 77 - const db = getDb(); 78 - const sphere = db 79 - .select({ id: spheres.id }) 80 - .from(spheres) 81 - .where(eq(spheres.ownerDid, sphereOwnerDid)) 82 - .get(); 83 - if (!sphere) return { allowed: false, sphereId: null }; 84 - const role = getActiveMemberRole(sphere.id, did); 85 - const allowed = checkPermission(sphere.id, MODULE_NAME, action, role); 86 - return { allowed, sphereId: sphere.id }; 87 - } 88 72 89 73 export const kanbanIndexer: ModuleIndexer = { 90 74 collections: [COLLECTION, COMMENT_COLLECTION, STATUS_COLLECTION], ··· 100 84 const subject = record.subject as string; 101 85 if (!subject || !subject.startsWith("did:")) return; 102 86 103 - const access = findSphereForAccess(subject, did, "create"); 87 + const access = findSphereForAccess(subject, did, MODULE_NAME, "create"); 104 88 if (!access.allowed || !access.sphereId) return; 105 89 106 90 const rawStatus = record.status as string;
+9 -8
packages/kanban/src/ui/pages/task.tsx
··· 400 400 const localLabelIds = useSignal<string[] | null>(null); 401 401 const labelSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null); 402 402 403 + const task = data?.task; 404 + const isAuthor = currentDid === task?.authorDid; 405 + const canEdit = isAuthor || canManage.value; 406 + const canEditLabels = canManage.value || isAuthor; 407 + 403 408 useEffect(() => { 404 - if (canManage.value) { 409 + if (canEditLabels) { 405 410 getLabels() 406 411 .then((res) => (availableLabels.value = res.labels)) 407 412 .catch(() => {}); 408 413 } 409 - }, [canManage.value]); 410 - 411 - const task = data?.task; 412 - const isAuthor = currentDid === task?.authorDid; 413 - const canEdit = isAuthor || canManage.value; 414 + }, [canEditLabels]); 414 415 415 416 const handleDelete = async () => { 416 417 if (!task) return; ··· 485 486 try { 486 487 await updateTaskLabels(task.id, localLabelIds.value!); 487 488 } catch { 488 - // Refetch to get the true server state 489 + refetch(); 489 490 } 490 491 }, 300); 491 492 }; ··· 601 602 </div> 602 603 </div> 603 604 604 - {canManage.value && availableLabels.value.length > 0 ? ( 605 + {canEditLabels && availableLabels.value.length > 0 ? ( 605 606 <InlineLabelEditor 606 607 labels={availableLabels.value} 607 608 selectedIds={localLabelIds.value ?? task.labels.map((l) => l.id)}