diff --git a/packages/core/src/sphere/index.ts b/packages/core/src/sphere/index.ts index d9fd3ec..9dccaa6 100644 --- a/packages/core/src/sphere/index.ts +++ b/packages/core/src/sphere/index.ts @@ -10,6 +10,7 @@ export { registerModerationHandler, registerLabelHandler, findSphereByAtUri, + findSphereForAccess, } from "./operations.ts"; export type { ModerationHandler, LabelHandler } from "./operations.ts"; export { sphereContext } from "./middleware.ts"; @@ -20,6 +21,7 @@ export { resolveLabelIdsByName, getOrCreateLabelRkey, upsertLabelRkey, + writeLabelPdsRecord, } from "./label-operations.ts"; export type { LabelInfo } from "./label-operations.ts"; export { setEntityLabelsSchema } from "./schemas.ts"; diff --git a/packages/core/src/sphere/label-operations.ts b/packages/core/src/sphere/label-operations.ts index a7983e5..6f0a741 100644 --- a/packages/core/src/sphere/label-operations.ts +++ b/packages/core/src/sphere/label-operations.ts @@ -2,7 +2,8 @@ import { eq, and, max, inArray, asc, count } from "../db/drizzle.ts"; import { getDb } from "../db/index.ts"; import { sphereLabels, entityLabels, labelPdsRecords } from "../db/schema/index.ts"; import type { SphereLabel, EntityType } from "../db/schema/index.ts"; -import { generateRkey } from "../pds.ts"; +import { generateRkey, putPdsRecord } from "../pds.ts"; +import type { OAuthSession } from "@atproto/oauth-client-node"; export function getLabelsForSphere(sphereId: string): SphereLabel[] { return getDb() @@ -209,3 +210,24 @@ export function setEntityLabels( } }); } + +const LABEL_COLLECTION = "site.exosphere.sphere.label"; + +/** Write (or update) the label PDS record for an entity. + * Returns the ISO timestamp used, or null if the write was skipped/failed. */ +export async function writeLabelPdsRecord( + session: OAuthSession, + entityId: string, + entityType: EntityType, + subjectUri: string, + labels: LabelInfo[], +): Promise { + const now = new Date().toISOString(); + const labelRkey = getOrCreateLabelRkey(entityId, entityType, session.did); + const uri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, { + subject: subjectUri, + labels: labels.map((l) => l.name), + updatedAt: now, + }); + return uri ? now : null; +} diff --git a/packages/core/src/sphere/operations.ts b/packages/core/src/sphere/operations.ts index a0d689f..6a19f86 100644 --- a/packages/core/src/sphere/operations.ts +++ b/packages/core/src/sphere/operations.ts @@ -59,6 +59,25 @@ export function findSphereByAtUri(sphereUri: string): { id: string } | null { ); } +/** Look up a sphere by owner DID and check if a user has a given module permission. */ +export function findSphereForAccess( + sphereOwnerDid: string, + did: string, + moduleName: string, + action: string, +): { allowed: boolean; sphereId: string | null } { + const db = getDb(); + const sphere = db + .select({ id: spheres.id }) + .from(spheres) + .where(eq(spheres.ownerDid, sphereOwnerDid)) + .get(); + if (!sphere) return { allowed: false, sphereId: null }; + const role = getActiveMemberRole(sphere.id, did); + const allowed = checkPermission(sphere.id, moduleName, action, role); + return { allowed, sphereId: sphere.id }; +} + interface UpsertSphereParams { did: string; rkey: string; @@ -269,7 +288,11 @@ export function deleteModulePermissions(did: string, moduleName: string): void { // ---- Moderation ---- /** Callback that tries to hide content by its pdsUri. Returns true if it handled the subject. */ -export type ModerationHandler = (subjectUri: string, moderatorDid: string) => boolean; +export type ModerationHandler = ( + subjectUri: string, + moderatorDid: string, + sphereId: string, +) => boolean; const moderationHandlers: ModerationHandler[] = []; @@ -287,11 +310,9 @@ export function indexModerationAction(did: string, record: Record & { id: beforeEach(() => { db = createTestDb(); seedSphere(db, { id: SPHERE_ID, handle: "test.bsky.social", ownerDid: AUTHOR_DID }); + // Add MOD_DID as admin so moderation permission checks pass + db.insert(sphereMembers) + .values({ sphereId: SPHERE_ID, did: MOD_DID, role: "admin", status: "active" }) + .run(); }); // ---- insertFeatureRequest ---- @@ -409,7 +414,7 @@ describe("handleFeatureRequestModeration", () => { const pdsUri = "at://did:plc:author1/com.exosphere.featureRequest/fr-1"; seedFR({ id: "fr-1", pdsUri }); - const handled = handleFeatureRequestModeration(pdsUri, MOD_DID); + const handled = handleFeatureRequestModeration(pdsUri, MOD_DID, SPHERE_ID); expect(handled).toBe(true); const fr = db.select().from(featureRequests).where(eq(featureRequests.id, "fr-1")).get(); @@ -427,7 +432,7 @@ describe("handleFeatureRequestModeration", () => { pdsUri: commentPdsUri, }); - const handled = handleFeatureRequestModeration(commentPdsUri, MOD_DID); + const handled = handleFeatureRequestModeration(commentPdsUri, MOD_DID, SPHERE_ID); expect(handled).toBe(true); const comment = db @@ -439,7 +444,7 @@ describe("handleFeatureRequestModeration", () => { }); it("returns false when pdsUri matches nothing", () => { - const handled = handleFeatureRequestModeration("at://unknown/col/rkey", MOD_DID); + const handled = handleFeatureRequestModeration("at://unknown/col/rkey", MOD_DID, SPHERE_ID); expect(handled).toBe(false); }); }); diff --git a/packages/feature-requests/src/api/requests.ts b/packages/feature-requests/src/api/requests.ts index 4221ebf..ed346d4 100644 --- a/packages/feature-requests/src/api/requests.ts +++ b/packages/feature-requests/src/api/requests.ts @@ -21,11 +21,10 @@ import { getLabelsForEntities, setEntityLabels, setEntityLabelsSchema, - getOrCreateLabelRkey, + writeLabelPdsRecord, } from "@exosphere/core/sphere"; const COLLECTION = "site.exosphere.featureRequest.entry"; -const LABEL_COLLECTION = "site.exosphere.sphere.label"; const MODERATION_COLLECTION = "site.exosphere.moderation"; const app = new Hono(); @@ -199,22 +198,11 @@ app.post("/", requireAuth, requirePermission("feature-requests", "create"), asyn ? (getLabelsForEntities([id], "feature-request").get(id) ?? []) : []; - // Write label PDS record if labels were set and entity has a PDS URI if (pdsUri && labels.length > 0) { const session = c.var.session; - const now = new Date().toISOString(); - const labelRkey = getOrCreateLabelRkey(id, "feature-request", did); - const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, { - subject: pdsUri, - labels: labels.map((l) => l.name), - updatedAt: now, - }); - if (labelPdsUri) { - const db = getDb(); - db.update(featureRequests) - .set({ labelUpdatedAt: now }) - .where(eq(featureRequests.id, id)) - .run(); + const labelUpdatedAt = await writeLabelPdsRecord(session, id, "feature-request", pdsUri, labels); + if (labelUpdatedAt) { + getDb().update(featureRequests).set({ labelUpdatedAt }).where(eq(featureRequests.id, id)).run(); } } @@ -444,21 +432,11 @@ app.post("/:id/labels", requireAuth, async (c) => { setEntityLabels(sphereId, id, "feature-request", parsed.data.labelIds); const labels = getLabelsForEntities([id], "feature-request").get(id) ?? []; - // Write label PDS record (reuse existing rkey if this user already wrote one) if (c.var.sphereVisibility === "public" && existing.pdsUri) { const session = c.var.session; - const now = new Date().toISOString(); - const labelRkey = getOrCreateLabelRkey(id, "feature-request", did); - const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, { - subject: existing.pdsUri, - labels: labels.map((l) => l.name), - updatedAt: now, - }); - if (labelPdsUri) { - db.update(featureRequests) - .set({ labelUpdatedAt: now }) - .where(eq(featureRequests.id, id)) - .run(); + const labelUpdatedAt = await writeLabelPdsRecord(session, id, "feature-request", existing.pdsUri, labels); + if (labelUpdatedAt) { + db.update(featureRequests).set({ labelUpdatedAt }).where(eq(featureRequests.id, id)).run(); } } diff --git a/packages/feature-requests/src/db/operations.ts b/packages/feature-requests/src/db/operations.ts index 424847a..9845ee5 100644 --- a/packages/feature-requests/src/db/operations.ts +++ b/packages/feature-requests/src/db/operations.ts @@ -4,6 +4,8 @@ import { entityLabels } from "@exosphere/core/db/schema"; import { nextEntryNumber } from "@exosphere/core/db/entry-number"; import { tidToDate } from "@exosphere/core/pds"; import type { ModerationHandler } from "@exosphere/core/sphere"; +import { getActiveMemberRole } from "@exosphere/core/sphere"; +import { checkPermission } from "@exosphere/core/permissions"; import { featureRequests, featureRequestVotes, @@ -229,7 +231,14 @@ export function unhideComment(id: string): void { } /** Moderation handler for feature requests and comments. Returns true if it handled the subject. */ -export const handleFeatureRequestModeration: ModerationHandler = (subjectUri, moderatorDid) => { +export const handleFeatureRequestModeration: ModerationHandler = ( + subjectUri, + moderatorDid, + sphereId, +) => { + const role = getActiveMemberRole(sphereId, moderatorDid); + if (!checkPermission(sphereId, "feature-requests", "moderate", role)) return false; + const db = getDb(); const fr = db diff --git a/packages/feature-requests/src/indexer.ts b/packages/feature-requests/src/indexer.ts index 8a79a1b..7abd2f5 100644 --- a/packages/feature-requests/src/indexer.ts +++ b/packages/feature-requests/src/indexer.ts @@ -4,6 +4,7 @@ import { registerModerationHandler, registerLabelHandler, getActiveMemberRole, + findSphereForAccess, setEntityLabels, resolveLabelIdsByName, upsertLabelRkey, @@ -11,7 +12,6 @@ import { import { checkPermission } from "@exosphere/core/permissions"; import { getDb } from "@exosphere/core/db"; import { eq, and } from "@exosphere/core/db/drizzle"; -import { spheres } from "@exosphere/core/db/schema"; import { featureRequests, featureRequestComments } from "./db/schema.ts"; import { statuses } from "./schemas/feature-request.ts"; import type { Status } from "./schemas/feature-request.ts"; @@ -75,22 +75,6 @@ registerLabelHandler((subjectUri, labelNames, actorDid, rkey, updatedAt) => { return true; }); -function findSphereForAccess( - sphereOwnerDid: string, - did: string, - action: string, -): { allowed: boolean; sphereId: string | null } { - const db = getDb(); - const sphere = db - .select({ id: spheres.id }) - .from(spheres) - .where(eq(spheres.ownerDid, sphereOwnerDid)) - .get(); - if (!sphere) return { allowed: false, sphereId: null }; - const role = getActiveMemberRole(sphere.id, did); - const allowed = checkPermission(sphere.id, MODULE_NAME, action, role); - return { allowed, sphereId: sphere.id }; -} export const featureRequestsIndexer: ModuleIndexer = { collections: [ @@ -112,7 +96,7 @@ export const featureRequestsIndexer: ModuleIndexer = { const subject = record.subject as string; if (!subject || !subject.startsWith("did:")) return; - const access = findSphereForAccess(subject, did, "create"); + const access = findSphereForAccess(subject, did, MODULE_NAME, "create"); if (!access.allowed || !access.sphereId) return; const sphereId = access.sphereId; diff --git a/packages/feature-requests/src/ui/pages/feature-request.tsx b/packages/feature-requests/src/ui/pages/feature-request.tsx index 68e4bcf..71f4d56 100644 --- a/packages/feature-requests/src/ui/pages/feature-request.tsx +++ b/packages/feature-requests/src/ui/pages/feature-request.tsx @@ -572,15 +572,17 @@ export function FeatureRequestPage() { const localLabelIds = useSignal(null); const labelSaveTimer = useRef | null>(null); + const fr = data?.featureRequest; + const isAuthor = currentDid != null && currentDid === fr?.authorDid; + const canEditLabels = canChangeStatus.value || isAuthor; + useEffect(() => { - if (canChangeStatus.value) { + if (canEditLabels) { getLabels() .then((res) => (availableLabels.value = res.labels)) .catch(() => {}); } - }, [canChangeStatus.value]); - - const fr = data?.featureRequest; + }, [canEditLabels]); const handleDelete = async () => { if (!fr) return; @@ -652,7 +654,7 @@ export function FeatureRequestPage() { try { await updateFeatureRequestLabels(fr.id, localLabelIds.value!); } catch { - // Refetch to get the true server state + refetch(); } }, 300); }; @@ -725,7 +727,7 @@ export function FeatureRequestPage() { ) : undefined } > - {canChangeStatus.value && availableLabels.value.length > 0 ? ( + {canEditLabels && availableLabels.value.length > 0 ? ( l.id)} diff --git a/packages/kanban/src/__tests__/db-operations.test.ts b/packages/kanban/src/__tests__/db-operations.test.ts index 1c9cc72..2c0e5f4 100644 --- a/packages/kanban/src/__tests__/db-operations.test.ts +++ b/packages/kanban/src/__tests__/db-operations.test.ts @@ -4,6 +4,7 @@ import { eq } from "drizzle-orm"; // Use the shared test-db helper from core (resolves via workspace) import { createTestDb, seedSphere } from "../../../core/src/__tests__/helpers/test-db.ts"; +import { sphereMembers } from "@exosphere/core/db/schema"; let db: BetterSQLite3Database; @@ -55,6 +56,10 @@ function seedTask(overrides: Partial & { id: st beforeEach(() => { db = createTestDb(); seedSphere(db, { id: SPHERE_ID, handle: "test.bsky.social", ownerDid: AUTHOR_DID }); + // Add MOD_DID as admin so moderation permission checks pass + db.insert(sphereMembers) + .values({ sphereId: SPHERE_ID, did: MOD_DID, role: "admin", status: "active" }) + .run(); }); // ---- insertTask ---- @@ -435,7 +440,7 @@ describe("handleKanbanModeration", () => { const pdsUri = "at://did:plc:author1/site.exosphere.kanban.entry/t-1"; seedTask({ id: "t-1", pdsUri }); - const handled = handleKanbanModeration(pdsUri, MOD_DID); + const handled = handleKanbanModeration(pdsUri, MOD_DID, SPHERE_ID); expect(handled).toBe(true); const task = db.select().from(kanbanTasks).where(eq(kanbanTasks.id, "t-1")).get(); @@ -453,7 +458,7 @@ describe("handleKanbanModeration", () => { pdsUri: commentPdsUri, }); - const handled = handleKanbanModeration(commentPdsUri, MOD_DID); + const handled = handleKanbanModeration(commentPdsUri, MOD_DID, SPHERE_ID); expect(handled).toBe(true); const comment = db @@ -465,7 +470,7 @@ describe("handleKanbanModeration", () => { }); it("returns false when pdsUri matches nothing", () => { - const handled = handleKanbanModeration("at://unknown/col/rkey", MOD_DID); + const handled = handleKanbanModeration("at://unknown/col/rkey", MOD_DID, SPHERE_ID); expect(handled).toBe(false); }); }); diff --git a/packages/kanban/src/api/tasks.ts b/packages/kanban/src/api/tasks.ts index b1e2c50..5856f3c 100644 --- a/packages/kanban/src/api/tasks.ts +++ b/packages/kanban/src/api/tasks.ts @@ -32,11 +32,10 @@ import { getLabelsForEntities, setEntityLabels, setEntityLabelsSchema, - getOrCreateLabelRkey, + writeLabelPdsRecord, } from "@exosphere/core/sphere"; const COLLECTION = "site.exosphere.kanban.entry"; -const LABEL_COLLECTION = "site.exosphere.sphere.label"; const STATUS_COLLECTION = "site.exosphere.kanban.status"; const MODERATION_COLLECTION = "site.exosphere.moderation"; @@ -218,19 +217,11 @@ app.post("/", requireAuth, requirePermission(MODULE, "create"), async (c) => { const labels = labelIds?.length ? (getLabelsForEntities([id], "kanban-task").get(id) ?? []) : []; - // Write label PDS record if labels were set and entity has a PDS URI if (pdsUri && labels.length > 0) { const session = c.var.session; - const now = new Date().toISOString(); - const labelRkey = getOrCreateLabelRkey(id, "kanban-task", did); - const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, { - subject: pdsUri, - labels: labels.map((l) => l.name), - updatedAt: now, - }); - if (labelPdsUri) { - const db = getDb(); - db.update(kanbanTasks).set({ labelUpdatedAt: now }).where(eq(kanbanTasks.id, id)).run(); + const labelUpdatedAt = await writeLabelPdsRecord(session, id, "kanban-task", pdsUri, labels); + if (labelUpdatedAt) { + getDb().update(kanbanTasks).set({ labelUpdatedAt }).where(eq(kanbanTasks.id, id)).run(); } } @@ -619,18 +610,11 @@ app.post("/:id/labels", requireAuth, async (c) => { setEntityLabels(sphereId, id, "kanban-task", parsed.data.labelIds); const labels = getLabelsForEntities([id], "kanban-task").get(id) ?? []; - // Write label PDS record (reuse existing rkey if this user already wrote one) if (c.var.sphereVisibility === "public" && existing.pdsUri) { const session = c.var.session; - const now = new Date().toISOString(); - const labelRkey = getOrCreateLabelRkey(id, "kanban-task", did); - const labelPdsUri = await putPdsRecord(session, LABEL_COLLECTION, labelRkey, { - subject: existing.pdsUri, - labels: labels.map((l) => l.name), - updatedAt: now, - }); - if (labelPdsUri) { - db.update(kanbanTasks).set({ labelUpdatedAt: now }).where(eq(kanbanTasks.id, id)).run(); + const labelUpdatedAt = await writeLabelPdsRecord(session, id, "kanban-task", existing.pdsUri, labels); + if (labelUpdatedAt) { + db.update(kanbanTasks).set({ labelUpdatedAt }).where(eq(kanbanTasks.id, id)).run(); } } diff --git a/packages/kanban/src/db/operations.ts b/packages/kanban/src/db/operations.ts index 1be75ab..ab6b308 100644 --- a/packages/kanban/src/db/operations.ts +++ b/packages/kanban/src/db/operations.ts @@ -4,6 +4,8 @@ import { entityLabels } from "@exosphere/core/db/schema"; import { nextEntryNumber } from "@exosphere/core/db/entry-number"; import { tidToDate, generateRkey } from "@exosphere/core/pds"; import type { ModerationHandler } from "@exosphere/core/sphere"; +import { getActiveMemberRole } from "@exosphere/core/sphere"; +import { checkPermission } from "@exosphere/core/permissions"; import { kanbanColumns, kanbanTasks, @@ -351,7 +353,10 @@ export function unhideComment(id: string): void { } /** Moderation handler for kanban tasks and comments. Returns true if it handled the subject. */ -export const handleKanbanModeration: ModerationHandler = (subjectUri, moderatorDid) => { +export const handleKanbanModeration: ModerationHandler = (subjectUri, moderatorDid, sphereId) => { + const role = getActiveMemberRole(sphereId, moderatorDid); + if (!checkPermission(sphereId, "kanban", "moderate", role)) return false; + const db = getDb(); const task = db diff --git a/packages/kanban/src/indexer.ts b/packages/kanban/src/indexer.ts index 05553bf..8c7a824 100644 --- a/packages/kanban/src/indexer.ts +++ b/packages/kanban/src/indexer.ts @@ -4,6 +4,7 @@ import { registerModerationHandler, registerLabelHandler, getActiveMemberRole, + findSphereForAccess, setEntityLabels, resolveLabelIdsByName, upsertLabelRkey, @@ -11,7 +12,6 @@ import { import { checkPermission } from "@exosphere/core/permissions"; import { getDb } from "@exosphere/core/db"; import { eq, and } from "@exosphere/core/db/drizzle"; -import { spheres } from "@exosphere/core/db/schema"; import { kanbanTasks, kanbanTaskComments } from "./db/schema.ts"; import { getColumns, @@ -69,22 +69,6 @@ registerLabelHandler((subjectUri, labelNames, actorDid, rkey, updatedAt) => { return true; }); -function findSphereForAccess( - sphereOwnerDid: string, - did: string, - action: string, -): { allowed: boolean; sphereId: string | null } { - const db = getDb(); - const sphere = db - .select({ id: spheres.id }) - .from(spheres) - .where(eq(spheres.ownerDid, sphereOwnerDid)) - .get(); - if (!sphere) return { allowed: false, sphereId: null }; - const role = getActiveMemberRole(sphere.id, did); - const allowed = checkPermission(sphere.id, MODULE_NAME, action, role); - return { allowed, sphereId: sphere.id }; -} export const kanbanIndexer: ModuleIndexer = { collections: [COLLECTION, COMMENT_COLLECTION, STATUS_COLLECTION], @@ -100,7 +84,7 @@ export const kanbanIndexer: ModuleIndexer = { const subject = record.subject as string; if (!subject || !subject.startsWith("did:")) return; - const access = findSphereForAccess(subject, did, "create"); + const access = findSphereForAccess(subject, did, MODULE_NAME, "create"); if (!access.allowed || !access.sphereId) return; const rawStatus = record.status as string; diff --git a/packages/kanban/src/ui/pages/task.tsx b/packages/kanban/src/ui/pages/task.tsx index 318415c..322383f 100644 --- a/packages/kanban/src/ui/pages/task.tsx +++ b/packages/kanban/src/ui/pages/task.tsx @@ -400,17 +400,18 @@ export function TaskPage() { const localLabelIds = useSignal(null); const labelSaveTimer = useRef | null>(null); + const task = data?.task; + const isAuthor = currentDid === task?.authorDid; + const canEdit = isAuthor || canManage.value; + const canEditLabels = canManage.value || isAuthor; + useEffect(() => { - if (canManage.value) { + if (canEditLabels) { getLabels() .then((res) => (availableLabels.value = res.labels)) .catch(() => {}); } - }, [canManage.value]); - - const task = data?.task; - const isAuthor = currentDid === task?.authorDid; - const canEdit = isAuthor || canManage.value; + }, [canEditLabels]); const handleDelete = async () => { if (!task) return; @@ -485,7 +486,7 @@ export function TaskPage() { try { await updateTaskLabels(task.id, localLabelIds.value!); } catch { - // Refetch to get the true server state + refetch(); } }, 300); }; @@ -601,7 +602,7 @@ export function TaskPage() { - {canManage.value && availableLabels.value.length > 0 ? ( + {canEditLabels && availableLabels.value.length > 0 ? ( l.id)}