Live video on the AT Protocol

Merge pull request #719 from streamplace/v/chat-mod-lexicon

Delegated moderation lexicons

authored by

Eli Mallon and committed by
GitHub
fef509be 1d8d7bf5

+5246 -157
+1 -1
Makefile
··· 310 310 && PKG_CONFIG_PATH=$(PKG_CONFIG_PATH) \ 311 311 LD_LIBRARY_PATH=$(shell realpath $(BUILDDIR))/lib \ 312 312 CGO_LDFLAGS="-lm" \ 313 - bash -euo pipefail -c "go test -p 1 -timeout 300s ./pkg/... -v | tee /dev/stderr | go-junit-report -out test.xml" 313 + bash -euo pipefail -c "go test -p 1 -timeout 30m ./pkg/... -v | tee /dev/stderr | go-junit-report -out test.xml" 314 314 315 315 .PHONY: iroh-test 316 316 iroh-test:
+16 -4
js/app/components/live-dashboard/livestream-panel.tsx
··· 2 2 Button, 3 3 Checkbox, 4 4 ContentMetadataForm, 5 + Dashboard, 5 6 formatHandle, 6 7 formatHandleWithAt, 7 8 Input, ··· 178 179 const [selectedImage, setSelectedImage] = useState< 179 180 string | File | Blob | undefined 180 181 >(); 181 - const [mode, setMode] = useState<"create" | "metadata">("create"); 182 + const [mode, setMode] = useState<"create" | "metadata" | "moderation">( 183 + "create", 184 + ); 182 185 183 186 const [createPost, setCreatePost] = useState(true); 184 187 const [sendPushNotification, setSendPushNotification] = useState(true); ··· 210 213 setCreatePost(typeof livestream.record.post !== "undefined"); 211 214 }, [livestream, defaultCanonicalUrl]); 212 215 213 - const handleModeChange = useCallback((newMode: "create" | "metadata") => { 214 - setMode(newMode); 215 - }, []); 216 + const handleModeChange = useCallback( 217 + (newMode: "create" | "metadata" | "moderation") => { 218 + setMode(newMode); 219 + }, 220 + [], 221 + ); 216 222 217 223 const handleSubmit = useCallback(async () => { 218 224 if (!title.trim()) return; ··· 370 376 values={[ 371 377 { label: "Create", value: "create" }, 372 378 { label: "Metadata", value: "metadata" }, 379 + { label: "Moderation", value: "moderation" }, 373 380 ]} 374 381 style={[{ marginVertical: -2 }]} 375 382 selectedValue={mode} ··· 385 392 showUpdateButton={!userIsLive} 386 393 style={{ flex: 1, height: "100%" }} 387 394 /> 395 + </View> 396 + ) : mode === "moderation" ? ( 397 + // Moderation view 398 + <View style={[flex.values[1], { minHeight: 400 }]}> 399 + <Dashboard.ModeratorPanel isLive={userIsLive} embedded={true} /> 388 400 </View> 389 401 ) : ( 390 402 // Create/Edit view
+434 -120
js/components/src/components/chat/mod-view.tsx
··· 1 1 import { TriggerRef, useRootContext } from "@rn-primitives/dropdown-menu"; 2 2 import { forwardRef, useEffect, useRef, useState } from "react"; 3 3 import { gap, mr, w } from "../../lib/theme/atoms"; 4 - import { useIsMyStream, usePlayerStore } from "../../player-store"; 4 + import { usePlayerStore } from "../../player-store"; 5 5 import { 6 6 useCreateBlockRecord, 7 7 useCreateHideChatRecord, 8 + useUpdateLivestreamRecord, 8 9 } from "../../streamplace-store/block"; 10 + import { 11 + ModerationPermissions, 12 + useCanModerate, 13 + } from "../../streamplace-store/moderation"; 9 14 import { usePDSAgent } from "../../streamplace-store/xrpc"; 10 15 11 16 import { Linking } from "react-native"; 12 17 import { ChatMessageViewHydrated } from "streamplace"; 13 - import { useDeleteChatMessage } from "../../livestream-store"; 18 + import { 19 + useDeleteChatMessage, 20 + useLivestream, 21 + useLivestreamStore, 22 + } from "../../livestream-store"; 14 23 import { useStreamplaceStore } from "../../streamplace-store"; 15 24 import { formatHandle, formatHandleWithAt } from "../../utils/format-handle"; 16 25 import { 17 26 atoms, 27 + Button, 28 + DialogFooter, 18 29 DropdownMenu, 19 30 DropdownMenuGroup, 20 31 DropdownMenuItem, 21 32 DropdownMenuTrigger, 22 33 layout, 34 + ResponsiveDialog, 23 35 ResponsiveDropdownMenuContent, 24 36 Text, 37 + Textarea, 25 38 useToast, 26 39 View, 27 40 } from "../ui"; ··· 42 55 export const ModView = forwardRef<ModViewRef, ModViewProps>(() => { 43 56 const triggerRef = useRef<TriggerRef>(null); 44 57 const message = usePlayerStore((state) => state.modMessage); 58 + const toast = useToast(); 45 59 46 60 let agent = usePDSAgent(); 47 61 let [messageRemoved, setMessageRemoved] = useState(false); 48 62 let { createBlock, isLoading: isBlockLoading } = useCreateBlockRecord(); 49 63 let { createHideChat, isLoading: isHideLoading } = useCreateHideChatRecord(); 64 + let { updateLivestream, isLoading: isUpdateTitleLoading } = 65 + useUpdateLivestreamRecord(); 66 + const livestream = useLivestream(); 67 + const [showUpdateTitleDialog, setShowUpdateTitleDialog] = useState(false); 50 68 51 69 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 52 70 const setReportSubject = usePlayerStore((x) => x.setReportSubject); 53 71 const setModMessage = usePlayerStore((x) => x.setModMessage); 54 72 const deleteChatMessage = useDeleteChatMessage(); 55 - const isMyStream = useIsMyStream(); 73 + 74 + // Get the streamer's DID from the livestream profile 75 + const streamerDID = useLivestreamStore((x) => x.profile?.did); 76 + // Check moderation permissions for the current user on this streamer's channel 77 + const modPermissions = useCanModerate(streamerDID); 56 78 57 79 // get the channel did 58 80 const channelId = usePlayerStore((state) => state.src); 59 81 // get the logged in user's identity 60 82 const handle = useStreamplaceStore((state) => state.handle); 61 83 62 - if (!agent?.did) { 63 - <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}> 64 - <Text>Log in to submit mod actions</Text> 65 - </View>; 66 - } 67 - 68 84 const cleanup = () => { 69 85 setModMessage(null); 70 86 }; 71 87 88 + // Effect must be called unconditionally (before any early returns) 72 89 useEffect(() => { 73 90 if (message) { 74 91 setMessageRemoved(false); ··· 78 95 } 79 96 }, [message]); 80 97 98 + // Early return AFTER all hooks have been called 99 + if (!agent?.did) { 100 + return ( 101 + <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}> 102 + <Text>Log in to submit mod actions</Text> 103 + </View> 104 + ); 105 + } 106 + 107 + // Can show moderation actions if user can hide, ban, or manage livestream 108 + const canModerate = 109 + modPermissions.canHide || 110 + modPermissions.canBan || 111 + modPermissions.canManageLivestream; 112 + 113 + // Check if any moderation actions are actually available for this message 114 + // This must match the individual action checks inside the DropdownMenuGroup 115 + const hasAvailableActions = !!( 116 + message && 117 + agent?.did && 118 + ((modPermissions.canHide && message.author.did !== streamerDID) || 119 + (modPermissions.canBan && 120 + message.author.did !== agent.did && 121 + message.author.did !== streamerDID)) 122 + ); 123 + 81 124 return ( 82 - <DropdownMenu 83 - style={[layout.flex.row, layout.flex.alignCenter, gap.all[2], w[80]]} 84 - onOpenChange={(isOpen) => { 85 - if (!isOpen) { 86 - cleanup(); 87 - } 88 - }} 89 - > 90 - <DropdownMenuTrigger ref={triggerRef}> 91 - {/* Hidden trigger */} 92 - <View /> 93 - </DropdownMenuTrigger> 94 - <ResponsiveDropdownMenuContent> 95 - {message && ( 96 - <> 97 - <DropdownMenuGroup> 98 - <DropdownMenuItem> 99 - <View 100 - style={[ 101 - layout.flex.column, 102 - mr[5], 103 - { gap: 6, maxWidth: "100%" }, 104 - ]} 105 - > 106 - <Text 107 - style={{ 108 - fontVariant: ["tabular-nums"], 109 - color: atoms.colors.gray[300], 110 - }} 111 - > 112 - {new Date(message.record.createdAt).toLocaleTimeString([], { 113 - hour: "2-digit", 114 - minute: "2-digit", 115 - hour12: false, 116 - })}{" "} 117 - {formatHandleWithAt(message.author)}: {message.record.text} 118 - </Text> 119 - </View> 120 - </DropdownMenuItem> 121 - </DropdownMenuGroup> 125 + <> 126 + <DropdownMenu 127 + style={[layout.flex.row, layout.flex.alignCenter, gap.all[2], w[80]]} 128 + onOpenChange={(isOpen) => { 129 + if (!isOpen) { 130 + cleanup(); 131 + } 132 + }} 133 + > 134 + <DropdownMenuTrigger ref={triggerRef}> 135 + {/* Hidden trigger */} 136 + <View /> 137 + </DropdownMenuTrigger> 138 + <ResponsiveDropdownMenuContent> 139 + {message && ( 140 + <ModViewContent 141 + message={message} 142 + modPermissions={modPermissions} 143 + agent={agent} 144 + streamerDID={streamerDID} 145 + hasAvailableActions={hasAvailableActions} 146 + isHideLoading={isHideLoading} 147 + isBlockLoading={isBlockLoading} 148 + messageRemoved={messageRemoved} 149 + setMessageRemoved={setMessageRemoved} 150 + createHideChat={createHideChat} 151 + createBlock={createBlock} 152 + toast={toast} 153 + setShowUpdateTitleDialog={setShowUpdateTitleDialog} 154 + isUpdateTitleLoading={isUpdateTitleLoading} 155 + livestream={livestream} 156 + setReportModalOpen={setReportModalOpen} 157 + setReportSubject={setReportSubject} 158 + deleteChatMessage={deleteChatMessage} 159 + /> 160 + )} 161 + </ResponsiveDropdownMenuContent> 162 + </DropdownMenu> 163 + 164 + {/* Update Stream Title Dialog - rendered outside dropdown */} 165 + {showUpdateTitleDialog && ( 166 + <UpdateStreamTitleDialog 167 + livestream={livestream} 168 + streamerDID={streamerDID} 169 + updateLivestream={updateLivestream} 170 + isLoading={isUpdateTitleLoading} 171 + onClose={() => setShowUpdateTitleDialog(false)} 172 + /> 173 + )} 174 + </> 175 + ); 176 + }); 177 + 178 + interface ModViewContentProps { 179 + message: ChatMessageViewHydrated; 180 + modPermissions: ModerationPermissions; 181 + agent: ReturnType<typeof usePDSAgent>; 182 + streamerDID?: string; 183 + hasAvailableActions: boolean; 184 + isHideLoading: boolean; 185 + isBlockLoading: boolean; 186 + messageRemoved: boolean; 187 + setMessageRemoved: (removed: boolean) => void; 188 + createHideChat: (uri: string, streamerDID?: string) => Promise<any>; 189 + createBlock: (did: string, streamerDID?: string) => Promise<any>; 190 + toast: ReturnType<typeof useToast>; 191 + setShowUpdateTitleDialog: (show: boolean) => void; 192 + isUpdateTitleLoading: boolean; 193 + livestream: any; 194 + setReportModalOpen: (open: boolean) => void; 195 + setReportSubject: (subject: any) => void; 196 + deleteChatMessage: (uri: string) => Promise<any>; 197 + } 198 + 199 + function ModViewContent({ 200 + message, 201 + modPermissions, 202 + agent, 203 + streamerDID, 204 + hasAvailableActions, 205 + isHideLoading, 206 + isBlockLoading, 207 + messageRemoved, 208 + setMessageRemoved, 209 + createHideChat, 210 + createBlock, 211 + toast, 212 + setShowUpdateTitleDialog, 213 + isUpdateTitleLoading, 214 + livestream, 215 + setReportModalOpen, 216 + setReportSubject, 217 + deleteChatMessage, 218 + }: ModViewContentProps) { 219 + const { onOpenChange } = useRootContext(); 122 220 123 - {/* TODO: Checking for non-owner moderators */} 124 - {isMyStream() && ( 125 - <DropdownMenuGroup title={`Moderation actions`}> 126 - <DropdownMenuItem 127 - disabled={isHideLoading || messageRemoved} 128 - onPress={() => { 129 - if (isHideLoading || messageRemoved) return; 130 - createHideChat(message.uri) 131 - .then((r) => setMessageRemoved(true)) 132 - .catch((e) => console.error(e)); 133 - }} 134 - > 135 - <Text 136 - color={ 137 - isHideLoading || messageRemoved ? "muted" : "destructive" 138 - } 139 - > 140 - {isHideLoading 141 - ? "Removing..." 142 - : messageRemoved 143 - ? "Message removed" 144 - : "Remove this message"} 145 - </Text> 146 - </DropdownMenuItem> 147 - <DropdownMenuItem 148 - disabled={message.author.did === agent?.did || isBlockLoading} 149 - onPress={() => { 150 - createBlock(message.author.did) 151 - .then((r) => console.log(r)) 152 - .catch((e) => console.error(e)); 153 - }} 154 - > 155 - {message.author.did === agent?.did ? ( 156 - <Text color="muted"> 157 - Block yourself (you can't block yourself) 158 - </Text> 159 - ) : ( 160 - <Text color="destructive"> 161 - {isBlockLoading 162 - ? "Blocking..." 163 - : `Block user ${formatHandleWithAt(message.author)} from this channel`} 164 - </Text> 165 - )} 166 - </DropdownMenuItem> 167 - </DropdownMenuGroup> 168 - )} 221 + return ( 222 + <> 223 + <DropdownMenuGroup key="message-display"> 224 + <DropdownMenuItem> 225 + <View 226 + style={[layout.flex.column, mr[5], { gap: 6, maxWidth: "100%" }]} 227 + > 228 + <Text 229 + style={{ 230 + fontVariant: ["tabular-nums"], 231 + color: atoms.colors.gray[300], 232 + }} 233 + > 234 + {new Date(message.record.createdAt).toLocaleTimeString([], { 235 + hour: "2-digit", 236 + minute: "2-digit", 237 + hour12: false, 238 + })}{" "} 239 + {formatHandleWithAt(message.author)}: {message.record.text} 240 + </Text> 241 + </View> 242 + </DropdownMenuItem> 243 + </DropdownMenuGroup> 169 244 170 - <DropdownMenuGroup title={`User actions`}> 245 + {hasAvailableActions && ( 246 + <DropdownMenuGroup 247 + key="moderation-actions" 248 + title={`Moderation actions`} 249 + > 250 + {modPermissions.canHide && message.author.did !== streamerDID && ( 251 + <DropdownMenuItem 252 + disabled={isHideLoading || messageRemoved} 253 + onPress={() => { 254 + if (isHideLoading || messageRemoved) return; 255 + createHideChat(message.uri, streamerDID ?? undefined) 256 + .then((r) => setMessageRemoved(true)) 257 + .catch((e) => console.error(e)); 258 + }} 259 + > 260 + <Text 261 + color={isHideLoading || messageRemoved ? "muted" : "warning"} 262 + > 263 + {isHideLoading 264 + ? "Hiding..." 265 + : messageRemoved 266 + ? "Message hidden" 267 + : "Hide this message"} 268 + </Text> 269 + </DropdownMenuItem> 270 + )} 271 + {modPermissions.canBan && 272 + agent?.did && 273 + message.author.did !== agent.did && 274 + message.author.did !== streamerDID && ( 171 275 <DropdownMenuItem 276 + disabled={isBlockLoading} 172 277 onPress={() => { 173 - Linking.openURL( 174 - `https://${BSKY_FRONTEND_DOMAIN}/profile/${formatHandle(message.author)}`, 175 - ); 278 + if (isBlockLoading) return; 279 + createBlock(message.author.did, streamerDID ?? undefined) 280 + .then((r) => { 281 + toast.show( 282 + "User blocked", 283 + `${formatHandleWithAt(message.author)} has been blocked from this channel.`, 284 + { duration: 3 }, 285 + ); 286 + onOpenChange?.(false); 287 + }) 288 + .catch((e) => { 289 + console.error(e); 290 + toast.show( 291 + "Error blocking user", 292 + e instanceof Error ? e.message : "Failed to block user", 293 + { duration: 5 }, 294 + ); 295 + }); 176 296 }} 177 297 > 178 - <Text color="primary">View user on {BSKY_FRONTEND_DOMAIN}</Text> 298 + <Text color="destructive"> 299 + {isBlockLoading 300 + ? "Blocking..." 301 + : `Block user ${formatHandleWithAt(message.author)} from this channel`} 302 + </Text> 179 303 </DropdownMenuItem> 180 - {message.author.did === agent?.did && ( 181 - <DeleteButton 182 - message={message} 183 - deleteChatMessage={deleteChatMessage} 184 - /> 185 - )} 186 - {message.author.did !== agent?.did && ( 187 - <ReportButton 188 - message={message} 189 - setReportModalOpen={setReportModalOpen} 190 - setReportSubject={setReportSubject} 191 - /> 192 - )} 193 - </DropdownMenuGroup> 194 - </> 304 + )} 305 + </DropdownMenuGroup> 306 + )} 307 + 308 + {modPermissions.canManageLivestream && ( 309 + <DropdownMenuGroup key="stream-actions" title={`Stream actions`}> 310 + <DropdownMenuItem 311 + onPress={() => { 312 + setShowUpdateTitleDialog(true); 313 + }} 314 + disabled={isUpdateTitleLoading || !livestream} 315 + > 316 + <Text 317 + color={isUpdateTitleLoading || !livestream ? "muted" : "primary"} 318 + > 319 + {isUpdateTitleLoading ? "Updating..." : "Update stream title"} 320 + </Text> 321 + </DropdownMenuItem> 322 + </DropdownMenuGroup> 323 + )} 324 + 325 + <DropdownMenuGroup key="user-actions" title={`User actions`}> 326 + <DropdownMenuItem 327 + onPress={() => { 328 + Linking.openURL( 329 + `https://${BSKY_FRONTEND_DOMAIN}/profile/${formatHandle(message.author)}`, 330 + ); 331 + }} 332 + > 333 + <Text color="primary">View user on {BSKY_FRONTEND_DOMAIN}</Text> 334 + </DropdownMenuItem> 335 + {message.author.did === agent?.did && ( 336 + <DeleteButton 337 + message={message} 338 + deleteChatMessage={deleteChatMessage} 339 + onOpenChange={onOpenChange} 340 + /> 341 + )} 342 + {message.author.did !== agent?.did && ( 343 + <ReportButton 344 + message={message} 345 + setReportModalOpen={setReportModalOpen} 346 + setReportSubject={setReportSubject} 347 + onOpenChange={onOpenChange} 348 + /> 195 349 )} 196 - </ResponsiveDropdownMenuContent> 197 - </DropdownMenu> 350 + </DropdownMenuGroup> 351 + </> 198 352 ); 199 - }); 353 + } 200 354 201 355 enum DeleteState { 202 356 None, ··· 207 361 export function DeleteButton({ 208 362 message, 209 363 deleteChatMessage, 364 + onOpenChange, 210 365 }: { 211 366 message: ChatMessageViewHydrated; 212 367 deleteChatMessage: (uri: string) => Promise<any>; 368 + onOpenChange?: (open: boolean) => void; 213 369 }) { 214 370 const [confirming, setConfirming] = useState<DeleteState>(DeleteState.None); 215 - const { onOpenChange } = useRootContext(); 216 371 const toast = useToast(); 217 372 return ( 218 373 <DropdownMenuItem ··· 253 408 message, 254 409 setReportModalOpen, 255 410 setReportSubject, 411 + onOpenChange, 256 412 }: { 257 413 message: ChatMessageViewHydrated; 258 414 setReportModalOpen: (open: boolean) => void; 259 415 setReportSubject: (subject: any) => void; 416 + onOpenChange?: (open: boolean) => void; 260 417 }) { 261 - const { onOpenChange } = useRootContext(); 262 418 return ( 263 419 <DropdownMenuItem 264 420 onPress={() => { ··· 277 433 </DropdownMenuItem> 278 434 ); 279 435 } 436 + 437 + interface UpdateStreamTitleDialogProps { 438 + livestream: any; 439 + streamerDID?: string; 440 + updateLivestream: ( 441 + livestreamUri: string, 442 + title: string, 443 + streamerDID?: string, 444 + ) => Promise<any>; 445 + isLoading: boolean; 446 + onClose: () => void; 447 + } 448 + 449 + function UpdateStreamTitleDialog({ 450 + livestream, 451 + streamerDID, 452 + updateLivestream, 453 + isLoading, 454 + onClose, 455 + }: UpdateStreamTitleDialogProps) { 456 + const [title, setTitle] = useState(livestream?.record?.title || ""); 457 + const [error, setError] = useState<string | null>(null); 458 + const toast = useToast(); 459 + 460 + useEffect(() => { 461 + if (livestream?.record?.title) { 462 + setTitle(livestream.record.title); 463 + } 464 + }, [livestream?.record?.title]); 465 + 466 + const handleUpdate = async () => { 467 + setError(null); 468 + 469 + if (!title.trim()) { 470 + setError("Please enter a stream title"); 471 + return; 472 + } 473 + 474 + if (!livestream?.uri) { 475 + setError("No livestream found"); 476 + return; 477 + } 478 + 479 + try { 480 + await updateLivestream(livestream.uri, title.trim(), streamerDID); 481 + toast.show( 482 + "Stream title updated", 483 + "The stream title has been successfully updated.", 484 + { duration: 3 }, 485 + ); 486 + onClose(); 487 + } catch (err) { 488 + setError( 489 + err instanceof Error ? err.message : "Failed to update stream title", 490 + ); 491 + } 492 + }; 493 + 494 + return ( 495 + <ResponsiveDialog 496 + open={true} 497 + onOpenChange={(open) => { 498 + if (!open) { 499 + onClose(); 500 + setError(null); 501 + setTitle(livestream?.record?.title || ""); 502 + } 503 + }} 504 + title="Update Stream Title" 505 + description="Update the title of the livestream." 506 + size="md" 507 + dismissible={false} 508 + > 509 + <View style={[{ padding: 16, paddingBottom: 0 }]}> 510 + <View style={[{ marginBottom: 16 }]}> 511 + <Text 512 + style={[ 513 + { color: atoms.colors.gray[300], fontSize: 13, marginBottom: 8 }, 514 + ]} 515 + > 516 + Stream Title 517 + </Text> 518 + <Textarea 519 + value={title} 520 + onChangeText={(text) => { 521 + setTitle(text); 522 + setError(null); 523 + }} 524 + placeholder="Enter stream title..." 525 + maxLength={140} 526 + multiline 527 + style={[ 528 + { 529 + padding: 12, 530 + borderRadius: 8, 531 + backgroundColor: atoms.colors.neutral[800], 532 + color: atoms.colors.white, 533 + borderWidth: 1, 534 + borderColor: atoms.colors.neutral[600], 535 + minHeight: 100, 536 + fontSize: 16, 537 + }, 538 + ]} 539 + /> 540 + <Text 541 + style={[ 542 + { color: atoms.colors.gray[400], fontSize: 12, marginTop: 4 }, 543 + ]} 544 + > 545 + {title.length}/140 characters 546 + </Text> 547 + </View> 548 + 549 + {error && ( 550 + <View 551 + style={[ 552 + { 553 + backgroundColor: atoms.colors.red[900], 554 + padding: 12, 555 + borderRadius: 8, 556 + borderWidth: 1, 557 + borderColor: atoms.colors.red[700], 558 + marginBottom: 16, 559 + }, 560 + ]} 561 + > 562 + <Text style={[{ color: atoms.colors.red[400], fontSize: 13 }]}> 563 + {error} 564 + </Text> 565 + </View> 566 + )} 567 + </View> 568 + 569 + <DialogFooter> 570 + <Button 571 + width="min" 572 + variant="secondary" 573 + onPress={() => { 574 + onClose(); 575 + setError(null); 576 + setTitle(livestream?.record?.title || ""); 577 + }} 578 + disabled={isLoading} 579 + > 580 + <Text>Cancel</Text> 581 + </Button> 582 + <Button 583 + variant="primary" 584 + width="min" 585 + onPress={handleUpdate} 586 + disabled={isLoading || !title.trim()} 587 + > 588 + <Text>{isLoading ? "Updating..." : "Update Title"}</Text> 589 + </Button> 590 + </DialogFooter> 591 + </ResponsiveDialog> 592 + ); 593 + }
+1
js/components/src/components/dashboard/index.tsx
··· 2 2 export { default as Header } from "./header"; 3 3 export { default as InformationWidget } from "./information-widget"; 4 4 export { default as ModActions } from "./mod-actions"; 5 + export { default as ModeratorPanel } from "./moderator-panel"; 5 6 export { default as Problems, ProblemsWrapperRef } from "./problems";
+630
js/components/src/components/dashboard/moderator-panel.tsx
··· 1 + import { Check, Shield, Trash2, UserPlus } from "lucide-react-native"; 2 + import { useEffect, useMemo, useState } from "react"; 3 + import { Pressable, ScrollView, Text, View } from "react-native"; 4 + import { 5 + Button, 6 + Dialog, 7 + DialogFooter, 8 + Input, 9 + ResponsiveDialog, 10 + useToast, 11 + } from "../../components/ui"; 12 + import { useAvatars } from "../../hooks/useAvatars"; 13 + import { atoms } from "../../lib/theme"; 14 + import { 15 + useAddModerator, 16 + useListModerators, 17 + useRemoveModerator, 18 + } from "../../streamplace-store/moderator-management"; 19 + import { formatHandleWithAt } from "../../utils/format-handle"; 20 + 21 + const { 22 + flex, 23 + bg, 24 + r, 25 + borders, 26 + p, 27 + text: textStyle, 28 + layout, 29 + gap, 30 + mb, 31 + my, 32 + px, 33 + py, 34 + } = atoms; 35 + 36 + interface ModeratorPanelProps { 37 + isLive?: boolean; 38 + embedded?: boolean; // If true, removes outer container styling (for use inside other panels) 39 + } 40 + 41 + export default function ModeratorPanel({ 42 + isLive, 43 + embedded = false, 44 + }: ModeratorPanelProps) { 45 + const { moderators, isLoading, error, refresh } = useListModerators(); 46 + const [showAddDialog, setShowAddDialog] = useState(false); 47 + 48 + // Collect all moderator DIDs for batch fetching profiles 49 + const moderatorDIDs = useMemo( 50 + () => moderators.map((mod) => mod.value.moderator), 51 + [moderators], 52 + ); 53 + 54 + const containerStyle = embedded 55 + ? [flex.values[1], layout.flex.column] 56 + : [ 57 + flex.values[1], 58 + bg.neutral[900], 59 + r.lg, 60 + borders.width.thin, 61 + borders.color.neutral[700], 62 + layout.flex.column, 63 + ]; 64 + 65 + return ( 66 + <View style={containerStyle}> 67 + {/* Header */} 68 + <View 69 + style={[ 70 + layout.flex.row, 71 + layout.flex.spaceBetween, 72 + layout.flex.alignCenter, 73 + borders.bottom.width.thin, 74 + borders.bottom.color.neutral[700], 75 + p[4], 76 + ]} 77 + > 78 + <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]}> 79 + <Shield size={18} color="#ffffff" /> 80 + <Text style={[textStyle.white, { fontSize: 18, fontWeight: "600" }]}> 81 + Moderators 82 + </Text> 83 + </View> 84 + <Pressable 85 + onPress={() => setShowAddDialog(true)} 86 + style={[ 87 + layout.flex.row, 88 + layout.flex.alignCenter, 89 + gap.all[2], 90 + bg.blue[600], 91 + px[3], 92 + py[2], 93 + r.md, 94 + ]} 95 + > 96 + <UserPlus size={16} color="#ffffff" /> 97 + <Text style={[textStyle.white, { fontSize: 13, fontWeight: "500" }]}> 98 + Add 99 + </Text> 100 + </Pressable> 101 + </View> 102 + 103 + {/* Content */} 104 + <ScrollView style={[flex.values[1], p[4]]}> 105 + {isLoading && moderators.length === 0 && ( 106 + <Text 107 + style={[textStyle.gray[400], { fontSize: 14, textAlign: "center" }]} 108 + > 109 + Loading moderators... 110 + </Text> 111 + )} 112 + 113 + {error && ( 114 + <View 115 + style={[ 116 + bg.red[900], 117 + p[3], 118 + r.md, 119 + borders.width.thin, 120 + borders.color.red[700], 121 + ]} 122 + > 123 + <Text style={[textStyle.red[400], { fontSize: 13 }]}>{error}</Text> 124 + </View> 125 + )} 126 + 127 + {!isLoading && moderators.length === 0 && !error && ( 128 + <View style={[layout.flex.center, p[6]]}> 129 + <Shield size={48} color="#6b7280" style={[mb[4]]} /> 130 + <Text 131 + style={[ 132 + textStyle.gray[400], 133 + { fontSize: 14, textAlign: "center", marginBottom: 8 }, 134 + ]} 135 + > 136 + No moderators yet 137 + </Text> 138 + <Text 139 + style={[ 140 + textStyle.gray[500], 141 + { fontSize: 12, textAlign: "center" }, 142 + ]} 143 + > 144 + Add moderators to help manage your chat 145 + </Text> 146 + </View> 147 + )} 148 + 149 + {moderators.map((mod, index) => ( 150 + <ModeratorCard 151 + key={mod.rkey} 152 + moderator={mod} 153 + onRemove={refresh} 154 + isLast={index === moderators.length - 1} 155 + moderatorDIDs={moderatorDIDs} 156 + /> 157 + ))} 158 + </ScrollView> 159 + 160 + {/* Add Moderator Dialog */} 161 + <AddModeratorDialog 162 + visible={showAddDialog} 163 + onClose={() => setShowAddDialog(false)} 164 + onSuccess={() => { 165 + setShowAddDialog(false); 166 + refresh(); 167 + }} 168 + /> 169 + </View> 170 + ); 171 + } 172 + 173 + interface ModeratorCardProps { 174 + moderator: { 175 + uri: string; 176 + cid: string; 177 + value: { 178 + moderator: string; 179 + permissions: string[]; 180 + createdAt: string; 181 + expirationTime?: string; 182 + }; 183 + rkey: string; 184 + }; 185 + onRemove: () => void; 186 + isLast: boolean; 187 + moderatorDIDs: string[]; // All moderator DIDs for batch fetching 188 + } 189 + 190 + function ModeratorCard({ 191 + moderator, 192 + onRemove, 193 + isLast, 194 + moderatorDIDs, 195 + }: ModeratorCardProps) { 196 + const { removeModerator, isLoading } = useRemoveModerator(); 197 + const [showConfirm, setShowConfirm] = useState(false); 198 + const toast = useToast(); 199 + 200 + // Use useAvatars hook to batch-fetch profiles for all moderators 201 + const profiles = useAvatars(moderatorDIDs); 202 + const profile = profiles[moderator.value.moderator]; 203 + 204 + // Format display name using existing utility 205 + const displayName = useMemo(() => { 206 + if (profile) { 207 + return formatHandleWithAt(profile); 208 + } 209 + return moderator.value.moderator; // Fall back to DID 210 + }, [profile, moderator.value.moderator]); 211 + 212 + const handleRemove = async () => { 213 + try { 214 + await removeModerator(moderator.rkey); 215 + setShowConfirm(false); 216 + toast.show( 217 + "Moderator removed", 218 + "The moderator has been successfully removed.", 219 + { duration: 3 }, 220 + ); 221 + onRemove(); 222 + } catch (err) { 223 + console.error("Failed to remove moderator:", err); 224 + toast.show( 225 + "Error removing moderator", 226 + err instanceof Error ? err.message : "Failed to remove moderator", 227 + { duration: 5 }, 228 + ); 229 + } 230 + }; 231 + 232 + const isExpired = 233 + moderator.value.expirationTime && 234 + new Date(moderator.value.expirationTime) < new Date(); 235 + 236 + return ( 237 + <View 238 + style={[ 239 + layout.flex.row, 240 + layout.flex.spaceBetween, 241 + layout.flex.alignCenter, 242 + p[3], 243 + bg.neutral[800], 244 + r.md, 245 + !isLast && mb[2], 246 + borders.width.thin, 247 + borders.color.neutral[700], 248 + ]} 249 + > 250 + <View style={[flex.values[1]]}> 251 + <Text 252 + style={[ 253 + textStyle.white, 254 + { fontSize: 14, fontWeight: "500", marginBottom: 4 }, 255 + ]} 256 + numberOfLines={1} 257 + > 258 + {displayName} 259 + </Text> 260 + <View style={[layout.flex.row, { flexWrap: "wrap" }, gap.all[1]]}> 261 + {moderator.value.permissions.map((perm) => ( 262 + <View 263 + key={perm} 264 + style={[ 265 + bg.blue[900], 266 + px[2], 267 + py[1], 268 + r.sm, 269 + borders.width.thin, 270 + borders.color.blue[700], 271 + ]} 272 + > 273 + <Text 274 + style={[ 275 + textStyle.blue[300], 276 + { fontSize: 11, fontWeight: "500" }, 277 + ]} 278 + > 279 + {perm} 280 + </Text> 281 + </View> 282 + ))} 283 + {isExpired && ( 284 + <View 285 + style={[ 286 + bg.red[900], 287 + px[2], 288 + py[1], 289 + r.sm, 290 + borders.width.thin, 291 + borders.color.red[700], 292 + ]} 293 + > 294 + <Text 295 + style={[ 296 + textStyle.red[300], 297 + { fontSize: 11, fontWeight: "500" }, 298 + ]} 299 + > 300 + Expired 301 + </Text> 302 + </View> 303 + )} 304 + </View> 305 + {moderator.value.expirationTime && !isExpired && ( 306 + <Text style={[textStyle.gray[400], { fontSize: 11, marginTop: 4 }]}> 307 + Expires:{" "} 308 + {new Date(moderator.value.expirationTime).toLocaleDateString()} 309 + </Text> 310 + )} 311 + </View> 312 + <Pressable 313 + onPress={() => setShowConfirm(true)} 314 + disabled={isLoading} 315 + style={[ 316 + p[2], 317 + r.md, 318 + bg.red[900], 319 + borders.width.thin, 320 + borders.color.red[700], 321 + isLoading && { opacity: 0.5 }, 322 + ]} 323 + > 324 + <Trash2 size={16} color="#f87171" /> 325 + </Pressable> 326 + 327 + {/* Confirm Delete Dialog */} 328 + <Dialog 329 + open={showConfirm} 330 + onOpenChange={setShowConfirm} 331 + title="Remove Moderator?" 332 + description="This will revoke all moderation permissions for this user." 333 + dismissible={false} 334 + > 335 + <DialogFooter> 336 + <Button 337 + variant="secondary" 338 + onPress={() => setShowConfirm(false)} 339 + disabled={isLoading} 340 + > 341 + <Text>Cancel</Text> 342 + </Button> 343 + <Button 344 + variant="destructive" 345 + onPress={handleRemove} 346 + disabled={isLoading} 347 + > 348 + <Text>{isLoading ? "Removing..." : "Remove"}</Text> 349 + </Button> 350 + </DialogFooter> 351 + </Dialog> 352 + </View> 353 + ); 354 + } 355 + 356 + interface AddModeratorDialogProps { 357 + visible: boolean; 358 + onClose: () => void; 359 + onSuccess: () => void; 360 + } 361 + 362 + function AddModeratorDialog({ 363 + visible, 364 + onClose, 365 + onSuccess, 366 + }: AddModeratorDialogProps) { 367 + const { addModerator, isLoading } = useAddModerator(); 368 + const [moderatorDID, setModeratorDID] = useState(""); 369 + const [permissions, setPermissions] = useState({ 370 + ban: false, 371 + hide: false, 372 + "livestream.manage": false, 373 + }); 374 + const [error, setError] = useState<string | null>(null); 375 + const toast = useToast(); 376 + 377 + // Reset form when dialog closes 378 + useEffect(() => { 379 + if (!visible) { 380 + setModeratorDID(""); 381 + setPermissions({ ban: false, hide: false, "livestream.manage": false }); 382 + setError(null); 383 + } 384 + }, [visible]); 385 + 386 + // Clear error when user starts typing 387 + const handleDIDChange = (text: string) => { 388 + setModeratorDID(text); 389 + if (error) setError(null); 390 + }; 391 + 392 + const handleAdd = async () => { 393 + setError(null); 394 + 395 + if (!moderatorDID.trim()) { 396 + setError("Please enter a DID or handle"); 397 + return; 398 + } 399 + 400 + const selectedPermissions = Object.entries(permissions) 401 + .filter(([_, enabled]) => enabled) 402 + .map(([perm]) => perm) as ("ban" | "hide" | "livestream.manage")[]; 403 + 404 + if (selectedPermissions.length === 0) { 405 + setError("Please select at least one permission"); 406 + return; 407 + } 408 + 409 + try { 410 + await addModerator({ 411 + moderatorDID: moderatorDID.trim(), 412 + permissions: selectedPermissions, 413 + }); 414 + toast.show( 415 + "Moderator added", 416 + "The new moderator has been added successfully.", 417 + { duration: 3 }, 418 + ); 419 + onSuccess(); 420 + } catch (err) { 421 + setError(err instanceof Error ? err.message : "Failed to add moderator"); 422 + } 423 + }; 424 + 425 + return ( 426 + <ResponsiveDialog 427 + open={visible} 428 + onOpenChange={(open) => { 429 + if (!open) onClose(); 430 + }} 431 + title="Add Moderator" 432 + description="Enter the DID or handle of the user you want to add as a moderator and select their permissions." 433 + size="md" 434 + dismissible={false} 435 + > 436 + {/* DID Input */} 437 + <View style={[my[4]]}> 438 + <Text style={[textStyle.gray[300], { fontSize: 13, marginBottom: 8 }]}> 439 + Moderator DID or Handle 440 + </Text> 441 + <Input 442 + value={moderatorDID} 443 + onChangeText={handleDIDChange} 444 + placeholder="did:plc:... or @handle.bsky.social" 445 + onSubmitEditing={handleAdd} 446 + returnKeyType="done" 447 + /> 448 + </View> 449 + 450 + {/* Permissions */} 451 + <View style={[mb[4]]}> 452 + <Text style={[textStyle.gray[300], { fontSize: 13, marginBottom: 8 }]}> 453 + Permissions 454 + </Text> 455 + <View style={[gap.all[2]]}> 456 + <Pressable 457 + onPress={() => setPermissions((p) => ({ ...p, ban: !p.ban }))} 458 + style={[ 459 + layout.flex.row, 460 + layout.flex.alignCenter, 461 + p[3], 462 + r.md, 463 + bg.neutral[800], 464 + borders.width.thin, 465 + borders.color.neutral[700], 466 + ]} 467 + > 468 + <View 469 + style={[ 470 + { 471 + width: 20, 472 + height: 20, 473 + borderRadius: 4, 474 + }, 475 + borders.width.thin, 476 + borders.color.neutral[600], 477 + permissions.ban ? bg.blue[600] : bg.neutral[900], 478 + layout.flex.center, 479 + { marginRight: 12 }, 480 + ]} 481 + > 482 + {permissions.ban && <Check size={12} color="white" />} 483 + </View> 484 + <View> 485 + <Text 486 + style={[textStyle.white, { fontSize: 14, fontWeight: "500" }]} 487 + > 488 + Ban Users 489 + </Text> 490 + <Text style={[textStyle.gray[400], { fontSize: 12 }]}> 491 + Block users from chat 492 + </Text> 493 + </View> 494 + </Pressable> 495 + 496 + <Pressable 497 + onPress={() => setPermissions((p) => ({ ...p, hide: !p.hide }))} 498 + style={[ 499 + layout.flex.row, 500 + layout.flex.alignCenter, 501 + p[3], 502 + r.md, 503 + bg.neutral[800], 504 + borders.width.thin, 505 + borders.color.neutral[700], 506 + ]} 507 + > 508 + <View 509 + style={[ 510 + { 511 + width: 20, 512 + height: 20, 513 + borderRadius: 4, 514 + }, 515 + borders.width.thin, 516 + borders.color.neutral[600], 517 + permissions.hide ? bg.blue[600] : bg.neutral[900], 518 + layout.flex.center, 519 + { marginRight: 12 }, 520 + ]} 521 + > 522 + {permissions.hide && ( 523 + <Text style={[textStyle.white, { fontSize: 12 }]}>✓</Text> 524 + )} 525 + </View> 526 + <View> 527 + <Text 528 + style={[textStyle.white, { fontSize: 14, fontWeight: "500" }]} 529 + > 530 + Hide Messages 531 + </Text> 532 + <Text style={[textStyle.gray[400], { fontSize: 12 }]}> 533 + Hide individual chat messages 534 + </Text> 535 + </View> 536 + </Pressable> 537 + 538 + <Pressable 539 + onPress={() => 540 + setPermissions((p) => ({ 541 + ...p, 542 + "livestream.manage": !p["livestream.manage"], 543 + })) 544 + } 545 + style={[ 546 + layout.flex.row, 547 + layout.flex.alignCenter, 548 + p[3], 549 + r.md, 550 + bg.neutral[800], 551 + borders.width.thin, 552 + borders.color.neutral[700], 553 + ]} 554 + > 555 + <View 556 + style={[ 557 + { 558 + width: 20, 559 + height: 20, 560 + borderRadius: 4, 561 + }, 562 + borders.width.thin, 563 + borders.color.neutral[600], 564 + permissions["livestream.manage"] 565 + ? bg.blue[600] 566 + : bg.neutral[900], 567 + layout.flex.center, 568 + { marginRight: 12 }, 569 + ]} 570 + > 571 + {permissions["livestream.manage"] && ( 572 + <Text style={[textStyle.white, { fontSize: 12 }]}>✓</Text> 573 + )} 574 + </View> 575 + <View> 576 + <Text 577 + style={[textStyle.white, { fontSize: 14, fontWeight: "500" }]} 578 + > 579 + Manage Livestream 580 + </Text> 581 + <Text style={[textStyle.gray[400], { fontSize: 12 }]}> 582 + Update stream title 583 + </Text> 584 + </View> 585 + </Pressable> 586 + </View> 587 + </View> 588 + 589 + {/* Error */} 590 + {error && ( 591 + <View 592 + style={[ 593 + bg.red[900], 594 + p[3], 595 + r.md, 596 + borders.width.thin, 597 + borders.color.red[700], 598 + mb[4], 599 + ]} 600 + > 601 + <Text style={[textStyle.red[400], { fontSize: 13 }]}>{error}</Text> 602 + </View> 603 + )} 604 + 605 + {/* Actions */} 606 + <DialogFooter> 607 + <Button 608 + width="min" 609 + variant="secondary" 610 + onPress={onClose} 611 + disabled={isLoading} 612 + > 613 + Cancel 614 + </Button> 615 + <Button 616 + width="min" 617 + variant="primary" 618 + onPress={handleAdd} 619 + disabled={ 620 + isLoading || 621 + !moderatorDID.trim() || 622 + Object.values(permissions).every((p) => !p) 623 + } 624 + > 625 + {isLoading ? "Adding..." : "Add Moderator"} 626 + </Button> 627 + </DialogFooter> 628 + </ResponsiveDialog> 629 + ); 630 + }
-2
js/components/src/livestream-store/chat.tsx
··· 374 374 subject: ComAtprotoModerationCreateReport.InputSchema["subject"], 375 375 reasonType: string, 376 376 reason?: string, 377 - // no clue about this 378 - moderationSvcDid: string = "did:web:stream.place", 379 377 ) => { 380 378 if (!pdsAgent || !userDID) { 381 379 throw new Error("No PDS agent or user DID found");
+5
js/components/src/livestream-store/livestream-state.tsx
··· 3 3 ChatMessageViewHydrated, 4 4 LivestreamViewHydrated, 5 5 PlaceStreamDefs, 6 + PlaceStreamModerationPermission, 6 7 PlaceStreamSegment, 7 8 } from "streamplace"; 8 9 ··· 23 24 setStreamKey: (key: string | null) => void; 24 25 websocketConnected: boolean; 25 26 hasReceivedSegment: boolean; 27 + moderationPermissions: PlaceStreamModerationPermission.Record[]; 28 + setModerationPermissions: ( 29 + permissions: PlaceStreamModerationPermission.Record[], 30 + ) => void; 26 31 } 27 32 28 33 export interface LivestreamProblem {
+2
js/components/src/livestream-store/livestream-store.tsx
··· 24 24 problems: [], 25 25 websocketConnected: false, 26 26 hasReceivedSegment: false, 27 + moderationPermissions: [], 28 + setModerationPermissions: (perms) => set({ moderationPermissions: perms }), 27 29 })); 28 30 }; 29 31
+60
js/components/src/livestream-store/websocket-consumer.tsx
··· 7 7 PlaceStreamChatMessage, 8 8 PlaceStreamDefs, 9 9 PlaceStreamLivestream, 10 + PlaceStreamModerationPermission, 10 11 PlaceStreamSegment, 11 12 } from "streamplace"; 12 13 import { SystemMessages } from "../lib/system-messages"; ··· 120 121 pendingHides: newPendingHides, 121 122 }; 122 123 state = reduceChat(state, [], [], [hiddenMessageUri]); 124 + } else if ( 125 + PlaceStreamModerationPermission.isRecord(message) || 126 + (message && 127 + typeof message === "object" && 128 + "$type" in message && 129 + (message as { $type?: string }).$type === 130 + "place.stream.moderation.permission") 131 + ) { 132 + // Handle moderation permission record updates 133 + // This can be a new permission or a deletion marker 134 + const permRecord = message as 135 + | PlaceStreamModerationPermission.Record 136 + | { deleted?: boolean; rkey?: string; streamer?: string }; 137 + 138 + if ((permRecord as any).deleted) { 139 + // Handle deletion: clear permissions to trigger refetch 140 + // The useCanModerate hook will refetch and repopulate 141 + state = { 142 + ...state, 143 + moderationPermissions: [], 144 + }; 145 + } else { 146 + // Handle new/updated permission: add or update in the list 147 + // Use createdAt as a unique identifier since multiple records can exist for the same moderator 148 + // (e.g., one record with "ban" permission, another with "hide" permission) 149 + // Note: rkey would be ideal but isn't always present in the WebSocket message 150 + const newPerm = 151 + permRecord as PlaceStreamModerationPermission.Record & { 152 + rkey?: string; 153 + }; 154 + const existingIndex = state.moderationPermissions.findIndex((p) => { 155 + const pWithRkey = p as PlaceStreamModerationPermission.Record & { 156 + rkey?: string; 157 + }; 158 + // Prefer matching by rkey if available, fall back to createdAt 159 + if (newPerm.rkey && pWithRkey.rkey) { 160 + return pWithRkey.rkey === newPerm.rkey; 161 + } 162 + return ( 163 + p.moderator === newPerm.moderator && 164 + p.createdAt === newPerm.createdAt 165 + ); 166 + }); 167 + 168 + let newPermissions: PlaceStreamModerationPermission.Record[]; 169 + if (existingIndex >= 0) { 170 + // Update existing record with same moderator AND createdAt 171 + newPermissions = [...state.moderationPermissions]; 172 + newPermissions[existingIndex] = newPerm; 173 + } else { 174 + // Add new record (could be a new record for an existing moderator with different permissions) 175 + newPermissions = [...state.moderationPermissions, newPerm]; 176 + } 177 + 178 + state = { 179 + ...state, 180 + moderationPermissions: newPermissions, 181 + }; 182 + } 123 183 } 124 184 } 125 185 }
+138 -18
js/components/src/streamplace-store/block.tsx
··· 2 2 import { useState } from "react"; 3 3 import { usePDSAgent } from "./xrpc"; 4 4 5 + /** 6 + * Hook to create a block record (ban user from chat). 7 + * 8 + * When the caller is the stream owner (agent.did === streamerDID), creates the 9 + * block record directly via ATProto writes to their repo. 10 + * 11 + * When the caller is a delegated moderator, uses the place.stream.moderation.createBlock 12 + * XRPC endpoint which validates permissions and creates the record using the 13 + * streamer's OAuth session. 14 + */ 5 15 export function useCreateBlockRecord() { 6 16 let agent = usePDSAgent(); 7 17 const [isLoading, setIsLoading] = useState(false); 8 18 9 - const createBlock = async (subjectDID: string) => { 19 + const createBlock = async (subjectDID: string, streamerDID?: string) => { 10 20 if (!agent) { 11 21 throw new Error("No PDS agent found"); 12 22 } ··· 17 27 18 28 setIsLoading(true); 19 29 try { 20 - const record: AppBskyGraphBlock.Record = { 21 - $type: "app.bsky.graph.block", 30 + // If no streamerDID provided or caller is the streamer, use direct ATProto write 31 + if (!streamerDID || agent.did === streamerDID) { 32 + const record: AppBskyGraphBlock.Record = { 33 + $type: "app.bsky.graph.block", 34 + subject: subjectDID, 35 + createdAt: new Date().toISOString(), 36 + }; 37 + const result = await agent.com.atproto.repo.createRecord({ 38 + repo: agent.did, 39 + collection: "app.bsky.graph.block", 40 + record, 41 + }); 42 + return result; 43 + } 44 + 45 + // Otherwise, use delegated moderation endpoint 46 + const result = await agent.place.stream.moderation.createBlock({ 47 + streamer: streamerDID, 22 48 subject: subjectDID, 23 - createdAt: new Date().toISOString(), 24 - }; 25 - const result = await agent.com.atproto.repo.createRecord({ 26 - repo: agent.did, 27 - collection: "app.bsky.graph.block", 28 - record, 29 49 }); 30 50 return result; 31 51 } finally { ··· 36 56 return { createBlock, isLoading }; 37 57 } 38 58 59 + /** 60 + * Hook to create a gate record (hide a chat message). 61 + * 62 + * When the caller is the stream owner (agent.did === streamerDID), creates the 63 + * gate record directly via ATProto writes to their repo. 64 + * 65 + * When the caller is a delegated moderator, uses the place.stream.moderation.createGate 66 + * XRPC endpoint which validates permissions and creates the record using the 67 + * streamer's OAuth session. 68 + */ 39 69 export function useCreateHideChatRecord() { 40 70 let agent = usePDSAgent(); 41 71 const [isLoading, setIsLoading] = useState(false); 42 72 43 - const createHideChat = async (chatMessageUri: string) => { 73 + const createHideChat = async ( 74 + chatMessageUri: string, 75 + streamerDID?: string, 76 + ) => { 44 77 if (!agent) { 45 78 throw new Error("No PDS agent found"); 46 79 } ··· 51 84 52 85 setIsLoading(true); 53 86 try { 54 - const record = { 55 - $type: "place.stream.chat.gate", 56 - hiddenMessage: chatMessageUri, 57 - }; 87 + // If no streamerDID provided or caller is the streamer, use direct ATProto write 88 + if (!streamerDID || agent.did === streamerDID) { 89 + const record = { 90 + $type: "place.stream.chat.gate", 91 + hiddenMessage: chatMessageUri, 92 + }; 93 + 94 + const result = await agent.com.atproto.repo.createRecord({ 95 + repo: agent.did, 96 + collection: "place.stream.chat.gate", 97 + record, 98 + }); 99 + return result; 100 + } 58 101 59 - const result = await agent.com.atproto.repo.createRecord({ 60 - repo: agent.did, 61 - collection: "place.stream.chat.gate", 62 - record, 102 + // Otherwise, use delegated moderation endpoint 103 + const result = await agent.place.stream.moderation.createGate({ 104 + streamer: streamerDID, 105 + messageUri: chatMessageUri, 63 106 }); 64 107 return result; 65 108 } finally { ··· 69 112 70 113 return { createHideChat, isLoading }; 71 114 } 115 + 116 + /** 117 + * Hook to update a livestream record (update stream title). 118 + * 119 + * When the caller is the stream owner (agent.did === streamerDID), updates the 120 + * livestream record directly via ATProto writes to their repo. 121 + * 122 + * When the caller is a delegated moderator, uses the place.stream.moderation.updateLivestream 123 + * XRPC endpoint which validates permissions and updates the record using the 124 + * streamer's OAuth session. 125 + */ 126 + export function useUpdateLivestreamRecord() { 127 + let agent = usePDSAgent(); 128 + const [isLoading, setIsLoading] = useState(false); 129 + 130 + const updateLivestream = async ( 131 + livestreamUri: string, 132 + title: string, 133 + streamerDID?: string, 134 + ) => { 135 + if (!agent) { 136 + throw new Error("No PDS agent found"); 137 + } 138 + 139 + if (!agent.did) { 140 + throw new Error("No user DID found, assuming not logged in"); 141 + } 142 + 143 + setIsLoading(true); 144 + try { 145 + // If no streamerDID provided or caller is the streamer, use direct ATProto write 146 + if (!streamerDID || agent.did === streamerDID) { 147 + // Extract rkey from URI 148 + const rkey = livestreamUri.split("/").pop(); 149 + if (!rkey) { 150 + throw new Error("Invalid livestream URI"); 151 + } 152 + 153 + // Get existing record to copy fields 154 + const getResult = await agent.com.atproto.repo.getRecord({ 155 + repo: agent.did, 156 + collection: "place.stream.livestream", 157 + rkey, 158 + }); 159 + 160 + const oldRecord = getResult.data.value as any; 161 + 162 + // Create new record (don't edit - old records are "chapter markers") 163 + // Spread entire record to preserve all fields (agent, canonicalUrl, notificationSettings, etc.) 164 + const record = { 165 + ...oldRecord, 166 + title: title, // Override title 167 + createdAt: new Date().toISOString(), // Override timestamp for new chapter marker 168 + }; 169 + 170 + const result = await agent.com.atproto.repo.createRecord({ 171 + repo: agent.did, 172 + collection: "place.stream.livestream", 173 + record, 174 + }); 175 + return result; 176 + } 177 + 178 + // Otherwise, use delegated moderation endpoint 179 + const result = await agent.place.stream.moderation.updateLivestream({ 180 + streamer: streamerDID, 181 + livestreamUri: livestreamUri, 182 + title: title, 183 + }); 184 + return result; 185 + } finally { 186 + setIsLoading(false); 187 + } 188 + }; 189 + 190 + return { updateLivestream, isLoading }; 191 + }
+3
js/components/src/streamplace-store/index.tsx
··· 1 + export * from "./block"; 2 + export * from "./moderation"; 3 + export * from "./moderator-management"; 1 4 export * from "./stream"; 2 5 export * from "./streamplace-store"; 3 6 export * from "./user";
+185
js/components/src/streamplace-store/moderation.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { PlaceStreamModerationPermission } from "streamplace"; 3 + import { useLivestreamStore } from "../livestream-store/livestream-store"; 4 + import { usePDSAgent } from "./xrpc"; 5 + 6 + export interface ModerationPermissions { 7 + canBan: boolean; 8 + canHide: boolean; 9 + canManageLivestream: boolean; 10 + isOwner: boolean; 11 + isLoading: boolean; 12 + error: string | null; 13 + } 14 + 15 + /** 16 + * Hook to check if the current user can moderate for a given streamer. 17 + * Returns permission flags based on: 18 + * - Owner: full permissions if userDID === streamerDID 19 + * - Delegated: permissions from place.stream.moderation.permission records 20 + */ 21 + export function useCanModerate( 22 + streamerDID: string | null | undefined, 23 + ): ModerationPermissions { 24 + const agent = usePDSAgent(); 25 + const userDID = agent?.did; 26 + 27 + // Get moderation permissions from livestream store (updated via WebSocket) 28 + const moderationPermissions = useLivestreamStore( 29 + (state) => state.moderationPermissions, 30 + ); 31 + const setModerationPermissions = useLivestreamStore( 32 + (state) => state.setModerationPermissions, 33 + ); 34 + 35 + const [isOwner, setIsOwner] = useState(false); 36 + const [isLoading, setIsLoading] = useState(false); 37 + const [error, setError] = useState<string | null>(null); 38 + 39 + useEffect(() => { 40 + if (!userDID || !streamerDID) { 41 + setModerationPermissions([]); 42 + setIsOwner(false); 43 + setError(null); 44 + return; 45 + } 46 + 47 + // If user is the streamer, they have full permissions 48 + if (userDID === streamerDID) { 49 + setIsOwner(true); 50 + setModerationPermissions([]); // Not needed for owner 51 + setIsLoading(false); 52 + setError(null); 53 + return; 54 + } 55 + 56 + // Otherwise, fetch delegation records from the streamer's repo 57 + // This initial fetch populates the store, then WebSocket updates will keep it in sync 58 + const fetchDelegation = async () => { 59 + if (!agent) { 60 + setModerationPermissions([]); 61 + setIsLoading(false); 62 + return; 63 + } 64 + 65 + setIsLoading(true); 66 + setError(null); 67 + setIsOwner(false); 68 + 69 + try { 70 + // Use authenticated agent to list permission records from the streamer's repo 71 + const result = await agent.com.atproto.repo.listRecords({ 72 + repo: streamerDID, 73 + collection: "place.stream.moderation.permission", 74 + limit: 100, 75 + }); 76 + 77 + const records = result.data.records || []; 78 + const permissionRecords: PlaceStreamModerationPermission.Record[] = 79 + records 80 + .map((r: { value: any }) => r.value) 81 + .filter( 82 + (v: any) => v && v.$type === "place.stream.moderation.permission", 83 + ); 84 + 85 + // Store all permissions in the livestream store 86 + // WebSocket updates will keep this in sync 87 + setModerationPermissions(permissionRecords); 88 + } catch (err) { 89 + console.error("[useCanModerate] Error fetching permissions:", err); 90 + setError( 91 + `Could not fetch moderation permissions: ${err instanceof Error ? err.message : `Unknown error: ${err}`}`, 92 + ); 93 + setModerationPermissions([]); 94 + } finally { 95 + setIsLoading(false); 96 + } 97 + }; 98 + 99 + // Fetch immediately on mount or when dependencies change 100 + fetchDelegation(); 101 + }, [userDID, streamerDID, agent, setModerationPermissions]); 102 + 103 + // If permissions were cleared (e.g., due to deletion), trigger a refetch 104 + useEffect(() => { 105 + // If permissions were cleared and we're not the owner, refetch 106 + if ( 107 + moderationPermissions.length === 0 && 108 + !isOwner && 109 + userDID && 110 + streamerDID && 111 + agent 112 + ) { 113 + const fetchDelegation = async () => { 114 + try { 115 + const result = await agent.com.atproto.repo.listRecords({ 116 + repo: streamerDID, 117 + collection: "place.stream.moderation.permission", 118 + limit: 100, 119 + }); 120 + 121 + const records = result.data.records || []; 122 + const permissionRecords: PlaceStreamModerationPermission.Record[] = 123 + records 124 + .map((r: { value: any }) => r.value) 125 + .filter( 126 + (v: any) => 127 + v && v.$type === "place.stream.moderation.permission", 128 + ); 129 + 130 + setModerationPermissions(permissionRecords); 131 + } catch (err) { 132 + console.error("[useCanModerate] Error refetching permissions:", err); 133 + } 134 + }; 135 + 136 + // Small delay to avoid rapid refetches 137 + const timeout = setTimeout(fetchDelegation, 100); 138 + return () => clearTimeout(timeout); 139 + } 140 + }, [ 141 + moderationPermissions.length, 142 + isOwner, 143 + userDID, 144 + streamerDID, 145 + agent, 146 + setModerationPermissions, 147 + ]); 148 + 149 + // Find ALL delegation records for this moderator and merge their permissions 150 + const delegations = moderationPermissions.filter( 151 + (perm) => perm.moderator === userDID, 152 + ); 153 + 154 + // Merge permissions from all delegation records for this moderator 155 + const permissions: string[] = delegations.reduce( 156 + (acc: string[], delegation) => { 157 + // Check if delegation has expired 158 + if (delegation.expirationTime) { 159 + const expiration = new Date(delegation.expirationTime); 160 + if (new Date() > expiration) { 161 + return acc; // Skip expired delegations 162 + } 163 + } 164 + 165 + // Add all permissions from this delegation, avoiding duplicates 166 + const delegationPerms = delegation.permissions || []; 167 + for (const perm of delegationPerms) { 168 + if (!acc.includes(perm)) { 169 + acc.push(perm); 170 + } 171 + } 172 + return acc; 173 + }, 174 + [], 175 + ); 176 + 177 + return { 178 + canBan: isOwner || permissions.includes("ban"), 179 + canHide: isOwner || permissions.includes("hide"), 180 + canManageLivestream: isOwner || permissions.includes("livestream.manage"), 181 + isOwner, 182 + isLoading, 183 + error, 184 + }; 185 + }
+175
js/components/src/streamplace-store/moderator-management.tsx
··· 1 + import { useCallback, useEffect, useState } from "react"; 2 + import { PlaceStreamModerationPermission } from "streamplace"; 3 + import { usePDSAgent } from "./xrpc"; 4 + 5 + interface ModeratorRecord { 6 + uri: string; 7 + cid: string; 8 + value: PlaceStreamModerationPermission.Record; 9 + rkey: string; 10 + } 11 + 12 + interface ListModeratorsResult { 13 + moderators: ModeratorRecord[]; 14 + isLoading: boolean; 15 + error: string | null; 16 + refresh: () => void; 17 + } 18 + 19 + /** 20 + * Hook to list all moderators for the current user's stream. 21 + * Fetches place.stream.moderation.permission records from the user's repo. 22 + */ 23 + export function useListModerators(): ListModeratorsResult { 24 + const agent = usePDSAgent(); 25 + const [moderators, setModerators] = useState<ModeratorRecord[]>([]); 26 + const [isLoading, setIsLoading] = useState(false); 27 + const [error, setError] = useState<string | null>(null); 28 + const [refreshTrigger, setRefreshTrigger] = useState(0); 29 + 30 + const refresh = useCallback(() => { 31 + setRefreshTrigger((prev) => prev + 1); 32 + }, []); 33 + 34 + useEffect(() => { 35 + if (!agent?.did) { 36 + setModerators([]); 37 + setError(null); 38 + return; 39 + } 40 + 41 + const fetchModerators = async () => { 42 + setIsLoading(true); 43 + setError(null); 44 + 45 + try { 46 + const result = await agent.place.stream.moderation.permission.list({ 47 + repo: agent.did!, 48 + }); 49 + 50 + const records = result.records.map((record: any) => { 51 + const rkey = record.uri.split("/").pop(); 52 + return { 53 + uri: record.uri, 54 + cid: record.cid || "", 55 + value: record.value, 56 + rkey: rkey || "", 57 + }; 58 + }); 59 + 60 + setModerators(records); 61 + } catch (err) { 62 + setError( 63 + `Failed to fetch moderators: ${err instanceof Error ? err.message : "Unknown error"}`, 64 + ); 65 + setModerators([]); 66 + } finally { 67 + setIsLoading(false); 68 + } 69 + }; 70 + 71 + fetchModerators(); 72 + }, [agent?.did, refreshTrigger]); 73 + 74 + return { 75 + moderators, 76 + isLoading, 77 + error, 78 + refresh, 79 + }; 80 + } 81 + 82 + interface AddModeratorParams { 83 + moderatorDID: string; 84 + permissions: ("ban" | "hide" | "livestream.manage")[]; 85 + expirationTime?: string; // ISO 8601 datetime string 86 + } 87 + 88 + /** 89 + * Hook to add a new moderator. 90 + * Creates a place.stream.moderation.permission record in the current user's repo. 91 + */ 92 + export function useAddModerator() { 93 + const agent = usePDSAgent(); 94 + const [isLoading, setIsLoading] = useState(false); 95 + 96 + const addModerator = async (params: AddModeratorParams) => { 97 + if (!agent?.did) { 98 + throw new Error("Not logged in"); 99 + } 100 + 101 + if (params.permissions.length === 0) { 102 + throw new Error("At least one permission must be selected"); 103 + } 104 + 105 + setIsLoading(true); 106 + try { 107 + // Resolve handle to DID if needed 108 + let moderatorDID = params.moderatorDID.trim(); 109 + if (!moderatorDID.startsWith("did:")) { 110 + if (moderatorDID.startsWith("@")) { 111 + moderatorDID = moderatorDID.substring(1); // Remove @ 112 + } 113 + try { 114 + const resolved = await agent.com.atproto.identity.resolveHandle({ 115 + handle: moderatorDID, 116 + }); 117 + moderatorDID = resolved.data.did; 118 + } catch (e) { 119 + throw new Error( 120 + `Invalid DID or handle: ${moderatorDID}. Please use a valid DID (did:plc:...) or handle (@handle.bsky.social)`, 121 + ); 122 + } 123 + } 124 + 125 + const record: PlaceStreamModerationPermission.Record = { 126 + $type: "place.stream.moderation.permission", 127 + moderator: moderatorDID, 128 + permissions: params.permissions, 129 + createdAt: new Date().toISOString(), 130 + }; 131 + 132 + if (params.expirationTime) { 133 + (record as any).expirationTime = params.expirationTime; 134 + } 135 + 136 + const result = await agent.place.stream.moderation.permission.create( 137 + { repo: agent.did }, 138 + record, 139 + ); 140 + 141 + return result; 142 + } finally { 143 + setIsLoading(false); 144 + } 145 + }; 146 + 147 + return { addModerator, isLoading }; 148 + } 149 + 150 + /** 151 + * Hook to remove a moderator. 152 + * Deletes a place.stream.moderation.permission record by its rkey. 153 + */ 154 + export function useRemoveModerator() { 155 + const agent = usePDSAgent(); 156 + const [isLoading, setIsLoading] = useState(false); 157 + 158 + const removeModerator = async (rkey: string) => { 159 + if (!agent?.did) { 160 + throw new Error("Not logged in"); 161 + } 162 + 163 + setIsLoading(true); 164 + try { 165 + await agent.place.stream.moderation.permission.delete({ 166 + repo: agent.did, 167 + rkey, 168 + }); 169 + } finally { 170 + setIsLoading(false); 171 + } 172 + }; 173 + 174 + return { removeModerator, isLoading }; 175 + }
+133
js/docs/src/content/docs/lex-reference/livestream/place-stream-livestream-update.md
··· 1 + --- 2 + title: place.stream.livestream.update 3 + description: Reference for the place.stream.livestream.update lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `procedure` 15 + 16 + Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' 17 + permission. Updates a place.stream.livestream record in the streamer's 18 + repository. 19 + 20 + **Parameters:** _(None defined)_ 21 + 22 + **Input:** 23 + 24 + - **Encoding:** `application/json` 25 + - **Schema:** 26 + 27 + **Schema Type:** `object` 28 + 29 + | Name | Type | Req'd | Description | Constraints | 30 + | --------------- | -------- | ----- | ---------------------------------------------- | --------------------------------------- | 31 + | `streamer` | `string` | ✅ | The DID of the streamer. | Format: `did` | 32 + | `livestreamUri` | `string` | ✅ | The AT-URI of the livestream record to update. | Format: `at-uri` | 33 + | `title` | `string` | ❌ | New title for the livestream. | Max Length: 1400<br/>Max Graphemes: 140 | 34 + 35 + **Output:** 36 + 37 + - **Encoding:** `application/json` 38 + - **Schema:** 39 + 40 + **Schema Type:** `object` 41 + 42 + | Name | Type | Req'd | Description | Constraints | 43 + | ----- | -------- | ----- | -------------------------------------------- | ---------------- | 44 + | `uri` | `string` | ✅ | The AT-URI of the updated livestream record. | Format: `at-uri` | 45 + | `cid` | `string` | ✅ | The CID of the updated livestream record. | Format: `cid` | 46 + 47 + **Possible Errors:** 48 + 49 + - `Unauthorized`: The request lacks valid authentication credentials. 50 + - `Forbidden`: The caller does not have permission to update livestream metadata 51 + for this streamer. 52 + - `SessionNotFound`: The streamer's OAuth session could not be found or is 53 + invalid. 54 + - `RecordNotFound`: The specified livestream record does not exist. 55 + 56 + --- 57 + 58 + ## Lexicon Source 59 + 60 + ```json 61 + { 62 + "lexicon": 1, 63 + "id": "place.stream.livestream.update", 64 + "defs": { 65 + "main": { 66 + "type": "procedure", 67 + "description": "Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' permission. Updates a place.stream.livestream record in the streamer's repository.", 68 + "input": { 69 + "encoding": "application/json", 70 + "schema": { 71 + "type": "object", 72 + "required": ["streamer", "livestreamUri"], 73 + "properties": { 74 + "streamer": { 75 + "type": "string", 76 + "format": "did", 77 + "description": "The DID of the streamer." 78 + }, 79 + "livestreamUri": { 80 + "type": "string", 81 + "format": "at-uri", 82 + "description": "The AT-URI of the livestream record to update." 83 + }, 84 + "title": { 85 + "type": "string", 86 + "maxLength": 1400, 87 + "maxGraphemes": 140, 88 + "description": "New title for the livestream." 89 + } 90 + } 91 + } 92 + }, 93 + "output": { 94 + "encoding": "application/json", 95 + "schema": { 96 + "type": "object", 97 + "required": ["uri", "cid"], 98 + "properties": { 99 + "uri": { 100 + "type": "string", 101 + "format": "at-uri", 102 + "description": "The AT-URI of the updated livestream record." 103 + }, 104 + "cid": { 105 + "type": "string", 106 + "format": "cid", 107 + "description": "The CID of the updated livestream record." 108 + } 109 + } 110 + } 111 + }, 112 + "errors": [ 113 + { 114 + "name": "Unauthorized", 115 + "description": "The request lacks valid authentication credentials." 116 + }, 117 + { 118 + "name": "Forbidden", 119 + "description": "The caller does not have permission to update livestream metadata for this streamer." 120 + }, 121 + { 122 + "name": "SessionNotFound", 123 + "description": "The streamer's OAuth session could not be found or is invalid." 124 + }, 125 + { 126 + "name": "RecordNotFound", 127 + "description": "The specified livestream record does not exist." 128 + } 129 + ] 130 + } 131 + } 132 + } 133 + ```
+126
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-createblock.md
··· 1 + --- 2 + title: place.stream.moderation.createBlock 3 + description: Reference for the place.stream.moderation.createBlock lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `procedure` 15 + 16 + Create a block (ban) on behalf of a streamer. Requires 'ban' permission. Creates 17 + an app.bsky.graph.block record in the streamer's repository. 18 + 19 + **Parameters:** _(None defined)_ 20 + 21 + **Input:** 22 + 23 + - **Encoding:** `application/json` 24 + - **Schema:** 25 + 26 + **Schema Type:** `object` 27 + 28 + | Name | Type | Req'd | Description | Constraints | 29 + | ---------- | -------- | ----- | --------------------------------------------------------- | --------------- | 30 + | `streamer` | `string` | ✅ | The DID of the streamer whose chat this block applies to. | Format: `did` | 31 + | `subject` | `string` | ✅ | The DID of the user being blocked from chat. | Format: `did` | 32 + | `reason` | `string` | ❌ | Optional reason for the block. | Max Length: 300 | 33 + 34 + **Output:** 35 + 36 + - **Encoding:** `application/json` 37 + - **Schema:** 38 + 39 + **Schema Type:** `object` 40 + 41 + | Name | Type | Req'd | Description | Constraints | 42 + | ----- | -------- | ----- | --------------------------------------- | ---------------- | 43 + | `uri` | `string` | ✅ | The AT-URI of the created block record. | Format: `at-uri` | 44 + | `cid` | `string` | ✅ | The CID of the created block record. | Format: `cid` | 45 + 46 + **Possible Errors:** 47 + 48 + - `Unauthorized`: The request lacks valid authentication credentials. 49 + - `Forbidden`: The caller does not have permission to create blocks for this 50 + streamer. 51 + - `SessionNotFound`: The streamer's OAuth session could not be found or is 52 + invalid. 53 + 54 + --- 55 + 56 + ## Lexicon Source 57 + 58 + ```json 59 + { 60 + "lexicon": 1, 61 + "id": "place.stream.moderation.createBlock", 62 + "defs": { 63 + "main": { 64 + "type": "procedure", 65 + "description": "Create a block (ban) on behalf of a streamer. Requires 'ban' permission. Creates an app.bsky.graph.block record in the streamer's repository.", 66 + "input": { 67 + "encoding": "application/json", 68 + "schema": { 69 + "type": "object", 70 + "required": ["streamer", "subject"], 71 + "properties": { 72 + "streamer": { 73 + "type": "string", 74 + "format": "did", 75 + "description": "The DID of the streamer whose chat this block applies to." 76 + }, 77 + "subject": { 78 + "type": "string", 79 + "format": "did", 80 + "description": "The DID of the user being blocked from chat." 81 + }, 82 + "reason": { 83 + "type": "string", 84 + "maxLength": 300, 85 + "description": "Optional reason for the block." 86 + } 87 + } 88 + } 89 + }, 90 + "output": { 91 + "encoding": "application/json", 92 + "schema": { 93 + "type": "object", 94 + "required": ["uri", "cid"], 95 + "properties": { 96 + "uri": { 97 + "type": "string", 98 + "format": "at-uri", 99 + "description": "The AT-URI of the created block record." 100 + }, 101 + "cid": { 102 + "type": "string", 103 + "format": "cid", 104 + "description": "The CID of the created block record." 105 + } 106 + } 107 + } 108 + }, 109 + "errors": [ 110 + { 111 + "name": "Unauthorized", 112 + "description": "The request lacks valid authentication credentials." 113 + }, 114 + { 115 + "name": "Forbidden", 116 + "description": "The caller does not have permission to create blocks for this streamer." 117 + }, 118 + { 119 + "name": "SessionNotFound", 120 + "description": "The streamer's OAuth session could not be found or is invalid." 121 + } 122 + ] 123 + } 124 + } 125 + } 126 + ```
+121
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-creategate.md
··· 1 + --- 2 + title: place.stream.moderation.createGate 3 + description: Reference for the place.stream.moderation.createGate lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `procedure` 15 + 16 + Create a gate (hide message) on behalf of a streamer. Requires 'hide' 17 + permission. Creates a place.stream.chat.gate record in the streamer's 18 + repository. 19 + 20 + **Parameters:** _(None defined)_ 21 + 22 + **Input:** 23 + 24 + - **Encoding:** `application/json` 25 + - **Schema:** 26 + 27 + **Schema Type:** `object` 28 + 29 + | Name | Type | Req'd | Description | Constraints | 30 + | ------------ | -------- | ----- | --------------------------------------- | ---------------- | 31 + | `streamer` | `string` | ✅ | The DID of the streamer. | Format: `did` | 32 + | `messageUri` | `string` | ✅ | The AT-URI of the chat message to hide. | Format: `at-uri` | 33 + 34 + **Output:** 35 + 36 + - **Encoding:** `application/json` 37 + - **Schema:** 38 + 39 + **Schema Type:** `object` 40 + 41 + | Name | Type | Req'd | Description | Constraints | 42 + | ----- | -------- | ----- | -------------------------------------- | ---------------- | 43 + | `uri` | `string` | ✅ | The AT-URI of the created gate record. | Format: `at-uri` | 44 + | `cid` | `string` | ✅ | The CID of the created gate record. | Format: `cid` | 45 + 46 + **Possible Errors:** 47 + 48 + - `Unauthorized`: The request lacks valid authentication credentials. 49 + - `Forbidden`: The caller does not have permission to hide messages for this 50 + streamer. 51 + - `SessionNotFound`: The streamer's OAuth session could not be found or is 52 + invalid. 53 + 54 + --- 55 + 56 + ## Lexicon Source 57 + 58 + ```json 59 + { 60 + "lexicon": 1, 61 + "id": "place.stream.moderation.createGate", 62 + "defs": { 63 + "main": { 64 + "type": "procedure", 65 + "description": "Create a gate (hide message) on behalf of a streamer. Requires 'hide' permission. Creates a place.stream.chat.gate record in the streamer's repository.", 66 + "input": { 67 + "encoding": "application/json", 68 + "schema": { 69 + "type": "object", 70 + "required": ["streamer", "messageUri"], 71 + "properties": { 72 + "streamer": { 73 + "type": "string", 74 + "format": "did", 75 + "description": "The DID of the streamer." 76 + }, 77 + "messageUri": { 78 + "type": "string", 79 + "format": "at-uri", 80 + "description": "The AT-URI of the chat message to hide." 81 + } 82 + } 83 + } 84 + }, 85 + "output": { 86 + "encoding": "application/json", 87 + "schema": { 88 + "type": "object", 89 + "required": ["uri", "cid"], 90 + "properties": { 91 + "uri": { 92 + "type": "string", 93 + "format": "at-uri", 94 + "description": "The AT-URI of the created gate record." 95 + }, 96 + "cid": { 97 + "type": "string", 98 + "format": "cid", 99 + "description": "The CID of the created gate record." 100 + } 101 + } 102 + } 103 + }, 104 + "errors": [ 105 + { 106 + "name": "Unauthorized", 107 + "description": "The request lacks valid authentication credentials." 108 + }, 109 + { 110 + "name": "Forbidden", 111 + "description": "The caller does not have permission to hide messages for this streamer." 112 + }, 113 + { 114 + "name": "SessionNotFound", 115 + "description": "The streamer's OAuth session could not be found or is invalid." 116 + } 117 + ] 118 + } 119 + } 120 + } 121 + ```
+61
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-defs.md
··· 1 + --- 2 + title: place.stream.moderation.defs 3 + description: Reference for the place.stream.moderation.defs lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="permissionview"></a> 11 + 12 + ### `permissionView` 13 + 14 + **Type:** `object` 15 + 16 + **Properties:** 17 + 18 + | Name | Type | Req'd | Description | Constraints | 19 + | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------------------------------------------- | ---------------- | 20 + | `uri` | `string` | ✅ | AT-URI of the permission record | Format: `at-uri` | 21 + | `cid` | `string` | ✅ | Content identifier of the permission record | Format: `cid` | 22 + | `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | ✅ | The streamer who granted these permissions | | 23 + | `record` | `unknown` | ✅ | The permission record itself | | 24 + 25 + --- 26 + 27 + ## Lexicon Source 28 + 29 + ```json 30 + { 31 + "lexicon": 1, 32 + "id": "place.stream.moderation.defs", 33 + "defs": { 34 + "permissionView": { 35 + "type": "object", 36 + "required": ["uri", "cid", "author", "record"], 37 + "properties": { 38 + "uri": { 39 + "type": "string", 40 + "format": "at-uri", 41 + "description": "AT-URI of the permission record" 42 + }, 43 + "cid": { 44 + "type": "string", 45 + "format": "cid", 46 + "description": "Content identifier of the permission record" 47 + }, 48 + "author": { 49 + "type": "ref", 50 + "ref": "app.bsky.actor.defs#profileViewBasic", 51 + "description": "The streamer who granted these permissions" 52 + }, 53 + "record": { 54 + "type": "unknown", 55 + "description": "The permission record itself" 56 + } 57 + } 58 + } 59 + } 60 + } 61 + ```
+103
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deleteblock.md
··· 1 + --- 2 + title: place.stream.moderation.deleteBlock 3 + description: Reference for the place.stream.moderation.deleteBlock lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `procedure` 15 + 16 + Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. 17 + Deletes an app.bsky.graph.block record from the streamer's repository. 18 + 19 + **Parameters:** _(None defined)_ 20 + 21 + **Input:** 22 + 23 + - **Encoding:** `application/json` 24 + - **Schema:** 25 + 26 + **Schema Type:** `object` 27 + 28 + | Name | Type | Req'd | Description | Constraints | 29 + | ---------- | -------- | ----- | ----------------------------------------- | ---------------- | 30 + | `streamer` | `string` | ✅ | The DID of the streamer. | Format: `did` | 31 + | `blockUri` | `string` | ✅ | The AT-URI of the block record to delete. | Format: `at-uri` | 32 + 33 + **Output:** 34 + 35 + - **Encoding:** `application/json` 36 + - **Schema:** 37 + 38 + **Schema Type:** `object` 39 + 40 + _(No properties defined)_ **Possible Errors:** 41 + 42 + - `Unauthorized`: The request lacks valid authentication credentials. 43 + - `Forbidden`: The caller does not have permission to delete blocks for this 44 + streamer. 45 + - `SessionNotFound`: The streamer's OAuth session could not be found or is 46 + invalid. 47 + 48 + --- 49 + 50 + ## Lexicon Source 51 + 52 + ```json 53 + { 54 + "lexicon": 1, 55 + "id": "place.stream.moderation.deleteBlock", 56 + "defs": { 57 + "main": { 58 + "type": "procedure", 59 + "description": "Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. Deletes an app.bsky.graph.block record from the streamer's repository.", 60 + "input": { 61 + "encoding": "application/json", 62 + "schema": { 63 + "type": "object", 64 + "required": ["streamer", "blockUri"], 65 + "properties": { 66 + "streamer": { 67 + "type": "string", 68 + "format": "did", 69 + "description": "The DID of the streamer." 70 + }, 71 + "blockUri": { 72 + "type": "string", 73 + "format": "at-uri", 74 + "description": "The AT-URI of the block record to delete." 75 + } 76 + } 77 + } 78 + }, 79 + "output": { 80 + "encoding": "application/json", 81 + "schema": { 82 + "type": "object", 83 + "properties": {} 84 + } 85 + }, 86 + "errors": [ 87 + { 88 + "name": "Unauthorized", 89 + "description": "The request lacks valid authentication credentials." 90 + }, 91 + { 92 + "name": "Forbidden", 93 + "description": "The caller does not have permission to delete blocks for this streamer." 94 + }, 95 + { 96 + "name": "SessionNotFound", 97 + "description": "The streamer's OAuth session could not be found or is invalid." 98 + } 99 + ] 100 + } 101 + } 102 + } 103 + ```
+104
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-deletegate.md
··· 1 + --- 2 + title: place.stream.moderation.deleteGate 3 + description: Reference for the place.stream.moderation.deleteGate lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `procedure` 15 + 16 + Delete a gate (unhide message) on behalf of a streamer. Requires 'hide' 17 + permission. Deletes a place.stream.chat.gate record from the streamer's 18 + repository. 19 + 20 + **Parameters:** _(None defined)_ 21 + 22 + **Input:** 23 + 24 + - **Encoding:** `application/json` 25 + - **Schema:** 26 + 27 + **Schema Type:** `object` 28 + 29 + | Name | Type | Req'd | Description | Constraints | 30 + | ---------- | -------- | ----- | ---------------------------------------- | ---------------- | 31 + | `streamer` | `string` | ✅ | The DID of the streamer. | Format: `did` | 32 + | `gateUri` | `string` | ✅ | The AT-URI of the gate record to delete. | Format: `at-uri` | 33 + 34 + **Output:** 35 + 36 + - **Encoding:** `application/json` 37 + - **Schema:** 38 + 39 + **Schema Type:** `object` 40 + 41 + _(No properties defined)_ **Possible Errors:** 42 + 43 + - `Unauthorized`: The request lacks valid authentication credentials. 44 + - `Forbidden`: The caller does not have permission to unhide messages for this 45 + streamer. 46 + - `SessionNotFound`: The streamer's OAuth session could not be found or is 47 + invalid. 48 + 49 + --- 50 + 51 + ## Lexicon Source 52 + 53 + ```json 54 + { 55 + "lexicon": 1, 56 + "id": "place.stream.moderation.deleteGate", 57 + "defs": { 58 + "main": { 59 + "type": "procedure", 60 + "description": "Delete a gate (unhide message) on behalf of a streamer. Requires 'hide' permission. Deletes a place.stream.chat.gate record from the streamer's repository.", 61 + "input": { 62 + "encoding": "application/json", 63 + "schema": { 64 + "type": "object", 65 + "required": ["streamer", "gateUri"], 66 + "properties": { 67 + "streamer": { 68 + "type": "string", 69 + "format": "did", 70 + "description": "The DID of the streamer." 71 + }, 72 + "gateUri": { 73 + "type": "string", 74 + "format": "at-uri", 75 + "description": "The AT-URI of the gate record to delete." 76 + } 77 + } 78 + } 79 + }, 80 + "output": { 81 + "encoding": "application/json", 82 + "schema": { 83 + "type": "object", 84 + "properties": {} 85 + } 86 + }, 87 + "errors": [ 88 + { 89 + "name": "Unauthorized", 90 + "description": "The request lacks valid authentication credentials." 91 + }, 92 + { 93 + "name": "Forbidden", 94 + "description": "The caller does not have permission to unhide messages for this streamer." 95 + }, 96 + { 97 + "name": "SessionNotFound", 98 + "description": "The streamer's OAuth session could not be found or is invalid." 99 + } 100 + ] 101 + } 102 + } 103 + } 104 + ```
+74
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-permission.md
··· 1 + --- 2 + title: place.stream.moderation.permission 3 + description: Reference for the place.stream.moderation.permission lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `record` 15 + 16 + Record granting moderation permissions to a user for this streamer's content. 17 + 18 + **Record Key:** `tid` 19 + 20 + **Record Properties:** 21 + 22 + | Name | Type | Req'd | Description | Constraints | 23 + | ---------------- | ----------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ | 24 + | `moderator` | `string` | ✅ | The DID of the user granted moderator permissions. | Format: `did` | 25 + | `permissions` | Array of `string` | ✅ | Array of permissions granted to this moderator. 'ban' covers blocks/bans (with optional expiration), 'hide' covers message gates, 'livestream.manage' allows updating livestream metadata. | | 26 + | `createdAt` | `string` | ✅ | Client-declared timestamp when this moderator was added. | Format: `datetime` | 27 + | `expirationTime` | `string` | ❌ | Optional expiration time for this delegation. If set, the delegation is invalid after this time. | Format: `datetime` | 28 + 29 + --- 30 + 31 + ## Lexicon Source 32 + 33 + ```json 34 + { 35 + "lexicon": 1, 36 + "id": "place.stream.moderation.permission", 37 + "defs": { 38 + "main": { 39 + "type": "record", 40 + "key": "tid", 41 + "description": "Record granting moderation permissions to a user for this streamer's content.", 42 + "record": { 43 + "type": "object", 44 + "required": ["moderator", "permissions", "createdAt"], 45 + "properties": { 46 + "moderator": { 47 + "type": "string", 48 + "format": "did", 49 + "description": "The DID of the user granted moderator permissions." 50 + }, 51 + "permissions": { 52 + "type": "array", 53 + "items": { 54 + "type": "string", 55 + "enum": ["ban", "hide", "livestream.manage"] 56 + }, 57 + "description": "Array of permissions granted to this moderator. 'ban' covers blocks/bans (with optional expiration), 'hide' covers message gates, 'livestream.manage' allows updating livestream metadata." 58 + }, 59 + "createdAt": { 60 + "type": "string", 61 + "format": "datetime", 62 + "description": "Client-declared timestamp when this moderator was added." 63 + }, 64 + "expirationTime": { 65 + "type": "string", 66 + "format": "datetime", 67 + "description": "Optional expiration time for this delegation. If set, the delegation is invalid after this time." 68 + } 69 + } 70 + } 71 + } 72 + } 73 + } 74 + ```
+133
js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-updatelivestream.md
··· 1 + --- 2 + title: place.stream.moderation.updateLivestream 3 + description: Reference for the place.stream.moderation.updateLivestream lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `procedure` 15 + 16 + Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' 17 + permission. Updates a place.stream.livestream record in the streamer's 18 + repository. 19 + 20 + **Parameters:** _(None defined)_ 21 + 22 + **Input:** 23 + 24 + - **Encoding:** `application/json` 25 + - **Schema:** 26 + 27 + **Schema Type:** `object` 28 + 29 + | Name | Type | Req'd | Description | Constraints | 30 + | --------------- | -------- | ----- | ---------------------------------------------- | --------------------------------------- | 31 + | `streamer` | `string` | ✅ | The DID of the streamer. | Format: `did` | 32 + | `livestreamUri` | `string` | ✅ | The AT-URI of the livestream record to update. | Format: `at-uri` | 33 + | `title` | `string` | ❌ | New title for the livestream. | Max Length: 1400<br/>Max Graphemes: 140 | 34 + 35 + **Output:** 36 + 37 + - **Encoding:** `application/json` 38 + - **Schema:** 39 + 40 + **Schema Type:** `object` 41 + 42 + | Name | Type | Req'd | Description | Constraints | 43 + | ----- | -------- | ----- | -------------------------------------------- | ---------------- | 44 + | `uri` | `string` | ✅ | The AT-URI of the updated livestream record. | Format: `at-uri` | 45 + | `cid` | `string` | ✅ | The CID of the updated livestream record. | Format: `cid` | 46 + 47 + **Possible Errors:** 48 + 49 + - `Unauthorized`: The request lacks valid authentication credentials. 50 + - `Forbidden`: The caller does not have permission to update livestream metadata 51 + for this streamer. 52 + - `SessionNotFound`: The streamer's OAuth session could not be found or is 53 + invalid. 54 + - `RecordNotFound`: The specified livestream record does not exist. 55 + 56 + --- 57 + 58 + ## Lexicon Source 59 + 60 + ```json 61 + { 62 + "lexicon": 1, 63 + "id": "place.stream.moderation.updateLivestream", 64 + "defs": { 65 + "main": { 66 + "type": "procedure", 67 + "description": "Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' permission. Updates a place.stream.livestream record in the streamer's repository.", 68 + "input": { 69 + "encoding": "application/json", 70 + "schema": { 71 + "type": "object", 72 + "required": ["streamer", "livestreamUri"], 73 + "properties": { 74 + "streamer": { 75 + "type": "string", 76 + "format": "did", 77 + "description": "The DID of the streamer." 78 + }, 79 + "livestreamUri": { 80 + "type": "string", 81 + "format": "at-uri", 82 + "description": "The AT-URI of the livestream record to update." 83 + }, 84 + "title": { 85 + "type": "string", 86 + "maxLength": 1400, 87 + "maxGraphemes": 140, 88 + "description": "New title for the livestream." 89 + } 90 + } 91 + } 92 + }, 93 + "output": { 94 + "encoding": "application/json", 95 + "schema": { 96 + "type": "object", 97 + "required": ["uri", "cid"], 98 + "properties": { 99 + "uri": { 100 + "type": "string", 101 + "format": "at-uri", 102 + "description": "The AT-URI of the updated livestream record." 103 + }, 104 + "cid": { 105 + "type": "string", 106 + "format": "cid", 107 + "description": "The CID of the updated livestream record." 108 + } 109 + } 110 + } 111 + }, 112 + "errors": [ 113 + { 114 + "name": "Unauthorized", 115 + "description": "The request lacks valid authentication credentials." 116 + }, 117 + { 118 + "name": "Forbidden", 119 + "description": "The caller does not have permission to update livestream metadata for this streamer." 120 + }, 121 + { 122 + "name": "SessionNotFound", 123 + "description": "The streamer's OAuth session could not be found or is invalid." 124 + }, 125 + { 126 + "name": "RecordNotFound", 127 + "description": "The specified livestream record does not exist." 128 + } 129 + ] 130 + } 131 + } 132 + } 133 + ```
+414
js/docs/src/content/docs/lex-reference/openapi.json
··· 517 517 } 518 518 } 519 519 }, 520 + "/xrpc/place.stream.moderation.createBlock": { 521 + "post": { 522 + "summary": "Create a block (ban) on behalf of a streamer. Requires 'ban' permission. Creates an app.bsky.graph.block record in the streamer's repository.", 523 + "operationId": "place.stream.moderation.createBlock", 524 + "tags": ["place.stream.moderation"], 525 + "responses": { 526 + "200": { 527 + "description": "Success", 528 + "content": { 529 + "application/json": { 530 + "schema": { 531 + "type": "object", 532 + "properties": { 533 + "uri": { 534 + "type": "string", 535 + "description": "The AT-URI of the created block record.", 536 + "format": "uri" 537 + }, 538 + "cid": { 539 + "type": "string", 540 + "description": "The CID of the created block record.", 541 + "format": "cid" 542 + } 543 + }, 544 + "required": ["uri", "cid"] 545 + } 546 + } 547 + } 548 + }, 549 + "400": { 550 + "description": "Bad Request", 551 + "content": { 552 + "application/json": { 553 + "schema": { 554 + "type": "object", 555 + "required": ["error", "message"], 556 + "properties": { 557 + "error": { 558 + "type": "string", 559 + "oneOf": [ 560 + { 561 + "const": "Unauthorized" 562 + }, 563 + { 564 + "const": "Forbidden" 565 + }, 566 + { 567 + "const": "SessionNotFound" 568 + } 569 + ] 570 + }, 571 + "message": { 572 + "type": "string" 573 + } 574 + } 575 + } 576 + } 577 + } 578 + } 579 + }, 580 + "requestBody": { 581 + "required": true, 582 + "content": { 583 + "application/json": { 584 + "schema": { 585 + "type": "object", 586 + "properties": { 587 + "streamer": { 588 + "type": "string", 589 + "description": "The DID of the streamer whose chat this block applies to.", 590 + "format": "did" 591 + }, 592 + "subject": { 593 + "type": "string", 594 + "description": "The DID of the user being blocked from chat.", 595 + "format": "did" 596 + }, 597 + "reason": { 598 + "type": "string", 599 + "description": "Optional reason for the block.", 600 + "maxLength": 300 601 + } 602 + }, 603 + "required": ["streamer", "subject"] 604 + } 605 + } 606 + } 607 + } 608 + } 609 + }, 610 + "/xrpc/place.stream.moderation.createGate": { 611 + "post": { 612 + "summary": "Create a gate (hide message) on behalf of a streamer. Requires 'hide' permission. Creates a place.stream.chat.gate record in the streamer's repository.", 613 + "operationId": "place.stream.moderation.createGate", 614 + "tags": ["place.stream.moderation"], 615 + "responses": { 616 + "200": { 617 + "description": "Success", 618 + "content": { 619 + "application/json": { 620 + "schema": { 621 + "type": "object", 622 + "properties": { 623 + "uri": { 624 + "type": "string", 625 + "description": "The AT-URI of the created gate record.", 626 + "format": "uri" 627 + }, 628 + "cid": { 629 + "type": "string", 630 + "description": "The CID of the created gate record.", 631 + "format": "cid" 632 + } 633 + }, 634 + "required": ["uri", "cid"] 635 + } 636 + } 637 + } 638 + }, 639 + "400": { 640 + "description": "Bad Request", 641 + "content": { 642 + "application/json": { 643 + "schema": { 644 + "type": "object", 645 + "required": ["error", "message"], 646 + "properties": { 647 + "error": { 648 + "type": "string", 649 + "oneOf": [ 650 + { 651 + "const": "Unauthorized" 652 + }, 653 + { 654 + "const": "Forbidden" 655 + }, 656 + { 657 + "const": "SessionNotFound" 658 + } 659 + ] 660 + }, 661 + "message": { 662 + "type": "string" 663 + } 664 + } 665 + } 666 + } 667 + } 668 + } 669 + }, 670 + "requestBody": { 671 + "required": true, 672 + "content": { 673 + "application/json": { 674 + "schema": { 675 + "type": "object", 676 + "properties": { 677 + "streamer": { 678 + "type": "string", 679 + "description": "The DID of the streamer.", 680 + "format": "did" 681 + }, 682 + "messageUri": { 683 + "type": "string", 684 + "description": "The AT-URI of the chat message to hide.", 685 + "format": "uri" 686 + } 687 + }, 688 + "required": ["streamer", "messageUri"] 689 + } 690 + } 691 + } 692 + } 693 + } 694 + }, 695 + "/xrpc/place.stream.moderation.deleteBlock": { 696 + "post": { 697 + "summary": "Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. Deletes an app.bsky.graph.block record from the streamer's repository.", 698 + "operationId": "place.stream.moderation.deleteBlock", 699 + "tags": ["place.stream.moderation"], 700 + "responses": { 701 + "200": { 702 + "description": "Success", 703 + "content": { 704 + "application/json": { 705 + "schema": { 706 + "type": "object", 707 + "properties": {} 708 + } 709 + } 710 + } 711 + }, 712 + "400": { 713 + "description": "Bad Request", 714 + "content": { 715 + "application/json": { 716 + "schema": { 717 + "type": "object", 718 + "required": ["error", "message"], 719 + "properties": { 720 + "error": { 721 + "type": "string", 722 + "oneOf": [ 723 + { 724 + "const": "Unauthorized" 725 + }, 726 + { 727 + "const": "Forbidden" 728 + }, 729 + { 730 + "const": "SessionNotFound" 731 + } 732 + ] 733 + }, 734 + "message": { 735 + "type": "string" 736 + } 737 + } 738 + } 739 + } 740 + } 741 + } 742 + }, 743 + "requestBody": { 744 + "required": true, 745 + "content": { 746 + "application/json": { 747 + "schema": { 748 + "type": "object", 749 + "properties": { 750 + "streamer": { 751 + "type": "string", 752 + "description": "The DID of the streamer.", 753 + "format": "did" 754 + }, 755 + "blockUri": { 756 + "type": "string", 757 + "description": "The AT-URI of the block record to delete.", 758 + "format": "uri" 759 + } 760 + }, 761 + "required": ["streamer", "blockUri"] 762 + } 763 + } 764 + } 765 + } 766 + } 767 + }, 768 + "/xrpc/place.stream.moderation.deleteGate": { 769 + "post": { 770 + "summary": "Delete a gate (unhide message) on behalf of a streamer. Requires 'hide' permission. Deletes a place.stream.chat.gate record from the streamer's repository.", 771 + "operationId": "place.stream.moderation.deleteGate", 772 + "tags": ["place.stream.moderation"], 773 + "responses": { 774 + "200": { 775 + "description": "Success", 776 + "content": { 777 + "application/json": { 778 + "schema": { 779 + "type": "object", 780 + "properties": {} 781 + } 782 + } 783 + } 784 + }, 785 + "400": { 786 + "description": "Bad Request", 787 + "content": { 788 + "application/json": { 789 + "schema": { 790 + "type": "object", 791 + "required": ["error", "message"], 792 + "properties": { 793 + "error": { 794 + "type": "string", 795 + "oneOf": [ 796 + { 797 + "const": "Unauthorized" 798 + }, 799 + { 800 + "const": "Forbidden" 801 + }, 802 + { 803 + "const": "SessionNotFound" 804 + } 805 + ] 806 + }, 807 + "message": { 808 + "type": "string" 809 + } 810 + } 811 + } 812 + } 813 + } 814 + } 815 + }, 816 + "requestBody": { 817 + "required": true, 818 + "content": { 819 + "application/json": { 820 + "schema": { 821 + "type": "object", 822 + "properties": { 823 + "streamer": { 824 + "type": "string", 825 + "description": "The DID of the streamer.", 826 + "format": "did" 827 + }, 828 + "gateUri": { 829 + "type": "string", 830 + "description": "The AT-URI of the gate record to delete.", 831 + "format": "uri" 832 + } 833 + }, 834 + "required": ["streamer", "gateUri"] 835 + } 836 + } 837 + } 838 + } 839 + } 840 + }, 841 + "/xrpc/place.stream.moderation.updateLivestream": { 842 + "post": { 843 + "summary": "Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' permission. Updates a place.stream.livestream record in the streamer's repository.", 844 + "operationId": "place.stream.moderation.updateLivestream", 845 + "tags": ["place.stream.moderation"], 846 + "responses": { 847 + "200": { 848 + "description": "Success", 849 + "content": { 850 + "application/json": { 851 + "schema": { 852 + "type": "object", 853 + "properties": { 854 + "uri": { 855 + "type": "string", 856 + "description": "The AT-URI of the updated livestream record.", 857 + "format": "uri" 858 + }, 859 + "cid": { 860 + "type": "string", 861 + "description": "The CID of the updated livestream record.", 862 + "format": "cid" 863 + } 864 + }, 865 + "required": ["uri", "cid"] 866 + } 867 + } 868 + } 869 + }, 870 + "400": { 871 + "description": "Bad Request", 872 + "content": { 873 + "application/json": { 874 + "schema": { 875 + "type": "object", 876 + "required": ["error", "message"], 877 + "properties": { 878 + "error": { 879 + "type": "string", 880 + "oneOf": [ 881 + { 882 + "const": "Unauthorized" 883 + }, 884 + { 885 + "const": "Forbidden" 886 + }, 887 + { 888 + "const": "SessionNotFound" 889 + }, 890 + { 891 + "const": "RecordNotFound" 892 + } 893 + ] 894 + }, 895 + "message": { 896 + "type": "string" 897 + } 898 + } 899 + } 900 + } 901 + } 902 + } 903 + }, 904 + "requestBody": { 905 + "required": true, 906 + "content": { 907 + "application/json": { 908 + "schema": { 909 + "type": "object", 910 + "properties": { 911 + "streamer": { 912 + "type": "string", 913 + "description": "The DID of the streamer.", 914 + "format": "did" 915 + }, 916 + "livestreamUri": { 917 + "type": "string", 918 + "description": "The AT-URI of the livestream record to update.", 919 + "format": "uri" 920 + }, 921 + "title": { 922 + "type": "string", 923 + "description": "New title for the livestream.", 924 + "maxLength": 1400 925 + } 926 + }, 927 + "required": ["streamer", "livestreamUri"] 928 + } 929 + } 930 + } 931 + } 932 + } 933 + }, 520 934 "/xrpc/place.stream.live.getLiveUsers": { 521 935 "get": { 522 936 "summary": "Get a list of livestream segments for a user",
+67
lexicons/place/stream/moderation/createBlock.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.moderation.createBlock", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a block (ban) on behalf of a streamer. Requires 'ban' permission. Creates an app.bsky.graph.block record in the streamer's repository.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["streamer", "subject"], 13 + "properties": { 14 + "streamer": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "The DID of the streamer whose chat this block applies to." 18 + }, 19 + "subject": { 20 + "type": "string", 21 + "format": "did", 22 + "description": "The DID of the user being blocked from chat." 23 + }, 24 + "reason": { 25 + "type": "string", 26 + "maxLength": 300, 27 + "description": "Optional reason for the block." 28 + } 29 + } 30 + } 31 + }, 32 + "output": { 33 + "encoding": "application/json", 34 + "schema": { 35 + "type": "object", 36 + "required": ["uri", "cid"], 37 + "properties": { 38 + "uri": { 39 + "type": "string", 40 + "format": "at-uri", 41 + "description": "The AT-URI of the created block record." 42 + }, 43 + "cid": { 44 + "type": "string", 45 + "format": "cid", 46 + "description": "The CID of the created block record." 47 + } 48 + } 49 + } 50 + }, 51 + "errors": [ 52 + { 53 + "name": "Unauthorized", 54 + "description": "The request lacks valid authentication credentials." 55 + }, 56 + { 57 + "name": "Forbidden", 58 + "description": "The caller does not have permission to create blocks for this streamer." 59 + }, 60 + { 61 + "name": "SessionNotFound", 62 + "description": "The streamer's OAuth session could not be found or is invalid." 63 + } 64 + ] 65 + } 66 + } 67 + }
+62
lexicons/place/stream/moderation/createGate.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.moderation.createGate", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a gate (hide message) on behalf of a streamer. Requires 'hide' permission. Creates a place.stream.chat.gate record in the streamer's repository.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["streamer", "messageUri"], 13 + "properties": { 14 + "streamer": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "The DID of the streamer." 18 + }, 19 + "messageUri": { 20 + "type": "string", 21 + "format": "at-uri", 22 + "description": "The AT-URI of the chat message to hide." 23 + } 24 + } 25 + } 26 + }, 27 + "output": { 28 + "encoding": "application/json", 29 + "schema": { 30 + "type": "object", 31 + "required": ["uri", "cid"], 32 + "properties": { 33 + "uri": { 34 + "type": "string", 35 + "format": "at-uri", 36 + "description": "The AT-URI of the created gate record." 37 + }, 38 + "cid": { 39 + "type": "string", 40 + "format": "cid", 41 + "description": "The CID of the created gate record." 42 + } 43 + } 44 + } 45 + }, 46 + "errors": [ 47 + { 48 + "name": "Unauthorized", 49 + "description": "The request lacks valid authentication credentials." 50 + }, 51 + { 52 + "name": "Forbidden", 53 + "description": "The caller does not have permission to hide messages for this streamer." 54 + }, 55 + { 56 + "name": "SessionNotFound", 57 + "description": "The streamer's OAuth session could not be found or is invalid." 58 + } 59 + ] 60 + } 61 + } 62 + }
+31
lexicons/place/stream/moderation/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.moderation.defs", 4 + "defs": { 5 + "permissionView": { 6 + "type": "object", 7 + "required": ["uri", "cid", "author", "record"], 8 + "properties": { 9 + "uri": { 10 + "type": "string", 11 + "format": "at-uri", 12 + "description": "AT-URI of the permission record" 13 + }, 14 + "cid": { 15 + "type": "string", 16 + "format": "cid", 17 + "description": "Content identifier of the permission record" 18 + }, 19 + "author": { 20 + "type": "ref", 21 + "ref": "app.bsky.actor.defs#profileViewBasic", 22 + "description": "The streamer who granted these permissions" 23 + }, 24 + "record": { 25 + "type": "unknown", 26 + "description": "The permission record itself" 27 + } 28 + } 29 + } 30 + } 31 + }
+50
lexicons/place/stream/moderation/deleteBlock.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.moderation.deleteBlock", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a block (unban) on behalf of a streamer. Requires 'ban' permission. Deletes an app.bsky.graph.block record from the streamer's repository.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["streamer", "blockUri"], 13 + "properties": { 14 + "streamer": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "The DID of the streamer." 18 + }, 19 + "blockUri": { 20 + "type": "string", 21 + "format": "at-uri", 22 + "description": "The AT-URI of the block record to delete." 23 + } 24 + } 25 + } 26 + }, 27 + "output": { 28 + "encoding": "application/json", 29 + "schema": { 30 + "type": "object", 31 + "properties": {} 32 + } 33 + }, 34 + "errors": [ 35 + { 36 + "name": "Unauthorized", 37 + "description": "The request lacks valid authentication credentials." 38 + }, 39 + { 40 + "name": "Forbidden", 41 + "description": "The caller does not have permission to delete blocks for this streamer." 42 + }, 43 + { 44 + "name": "SessionNotFound", 45 + "description": "The streamer's OAuth session could not be found or is invalid." 46 + } 47 + ] 48 + } 49 + } 50 + }
+50
lexicons/place/stream/moderation/deleteGate.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.moderation.deleteGate", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a gate (unhide message) on behalf of a streamer. Requires 'hide' permission. Deletes a place.stream.chat.gate record from the streamer's repository.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["streamer", "gateUri"], 13 + "properties": { 14 + "streamer": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "The DID of the streamer." 18 + }, 19 + "gateUri": { 20 + "type": "string", 21 + "format": "at-uri", 22 + "description": "The AT-URI of the gate record to delete." 23 + } 24 + } 25 + } 26 + }, 27 + "output": { 28 + "encoding": "application/json", 29 + "schema": { 30 + "type": "object", 31 + "properties": {} 32 + } 33 + }, 34 + "errors": [ 35 + { 36 + "name": "Unauthorized", 37 + "description": "The request lacks valid authentication credentials." 38 + }, 39 + { 40 + "name": "Forbidden", 41 + "description": "The caller does not have permission to unhide messages for this streamer." 42 + }, 43 + { 44 + "name": "SessionNotFound", 45 + "description": "The streamer's OAuth session could not be found or is invalid." 46 + } 47 + ] 48 + } 49 + } 50 + }
+40
lexicons/place/stream/moderation/permission.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.moderation.permission", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "Record granting moderation permissions to a user for this streamer's content.", 9 + "record": { 10 + "type": "object", 11 + "required": ["moderator", "permissions", "createdAt"], 12 + "properties": { 13 + "moderator": { 14 + "type": "string", 15 + "format": "did", 16 + "description": "The DID of the user granted moderator permissions." 17 + }, 18 + "permissions": { 19 + "type": "array", 20 + "items": { 21 + "type": "string", 22 + "enum": ["ban", "hide", "livestream.manage"] 23 + }, 24 + "description": "Array of permissions granted to this moderator. 'ban' covers blocks/bans (with optional expiration), 'hide' covers message gates, 'livestream.manage' allows updating livestream metadata." 25 + }, 26 + "createdAt": { 27 + "type": "string", 28 + "format": "datetime", 29 + "description": "Client-declared timestamp when this moderator was added." 30 + }, 31 + "expirationTime": { 32 + "type": "string", 33 + "format": "datetime", 34 + "description": "Optional expiration time for this delegation. If set, the delegation is invalid after this time." 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }
+72
lexicons/place/stream/moderation/updateLivestream.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.moderation.updateLivestream", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Update livestream metadata on behalf of a streamer. Requires 'livestream.manage' permission. Updates a place.stream.livestream record in the streamer's repository.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["streamer", "livestreamUri"], 13 + "properties": { 14 + "streamer": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "The DID of the streamer." 18 + }, 19 + "livestreamUri": { 20 + "type": "string", 21 + "format": "at-uri", 22 + "description": "The AT-URI of the livestream record to update." 23 + }, 24 + "title": { 25 + "type": "string", 26 + "maxLength": 1400, 27 + "maxGraphemes": 140, 28 + "description": "New title for the livestream." 29 + } 30 + } 31 + } 32 + }, 33 + "output": { 34 + "encoding": "application/json", 35 + "schema": { 36 + "type": "object", 37 + "required": ["uri", "cid"], 38 + "properties": { 39 + "uri": { 40 + "type": "string", 41 + "format": "at-uri", 42 + "description": "The AT-URI of the updated livestream record." 43 + }, 44 + "cid": { 45 + "type": "string", 46 + "format": "cid", 47 + "description": "The CID of the updated livestream record." 48 + } 49 + } 50 + } 51 + }, 52 + "errors": [ 53 + { 54 + "name": "Unauthorized", 55 + "description": "The request lacks valid authentication credentials." 56 + }, 57 + { 58 + "name": "Forbidden", 59 + "description": "The caller does not have permission to update livestream metadata for this streamer." 60 + }, 61 + { 62 + "name": "SessionNotFound", 63 + "description": "The streamer's OAuth session could not be found or is invalid." 64 + }, 65 + { 66 + "name": "RecordNotFound", 67 + "description": "The specified livestream record does not exist." 68 + } 69 + ] 70 + } 71 + } 72 + }
+25
pkg/atproto/firehose.go
··· 305 305 atsync.Bus.Publish(msg.StreamerRepoDID, mv) 306 306 } 307 307 308 + if collection.String() == constants.PLACE_STREAM_MODERATION_PERMISSION { 309 + log.Debug(ctx, "deleting moderation delegation", "userDID", evt.Repo, "rkey", rkey.String()) 310 + err := atsync.Model.DeleteModerationDelegation(ctx, rkey.String()) 311 + if err != nil { 312 + log.Error(ctx, "failed to delete moderation delegation", "err", err) 313 + } 314 + // Publish deletion to WebSocket bus for real-time updates 315 + // Create a deleted record marker to notify frontend 316 + deletedRecord := map[string]any{ 317 + "$type": "place.stream.moderation.permission", 318 + "deleted": true, 319 + "rkey": rkey.String(), 320 + "streamer": evt.Repo, 321 + } 322 + go atsync.Bus.Publish(evt.Repo, deletedRecord) 323 + } 324 + 325 + if collection.String() == constants.PLACE_STREAM_CHAT_GATE { 326 + log.Debug(ctx, "deleting gate", "userDID", evt.Repo, "rkey", rkey.String()) 327 + err := atsync.Model.DeleteGate(ctx, rkey.String()) 328 + if err != nil { 329 + log.Error(ctx, "failed to delete gate", "err", err) 330 + } 331 + } 332 + 308 333 default: 309 334 log.Error(ctx, "unexpected record op kind") 310 335 }
+239
pkg/atproto/moderation_test.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + "testing" 8 + "time" 9 + 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/api/bsky" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + lexutil "github.com/bluesky-social/indigo/lex/util" 14 + "github.com/bluesky-social/indigo/util" 15 + "github.com/stretchr/testify/require" 16 + "stream.place/streamplace/pkg/bus" 17 + "stream.place/streamplace/pkg/config" 18 + "stream.place/streamplace/pkg/constants" 19 + "stream.place/streamplace/pkg/devenv" 20 + "stream.place/streamplace/pkg/model" 21 + "stream.place/streamplace/pkg/statedb" 22 + "stream.place/streamplace/pkg/streamplace" 23 + ) 24 + 25 + func TestDelegatedModeration(t *testing.T) { 26 + dev := devenv.WithDevEnv(t) 27 + t.Logf("dev: %+v", dev) 28 + cli := config.CLI{ 29 + BroadcasterHost: "example.com", 30 + DBURL: ":memory:", 31 + RelayHost: strings.ReplaceAll(dev.PDSURL, "http://", "ws://"), 32 + PLCURL: dev.PLCURL, 33 + } 34 + t.Logf("cli: %+v", cli) 35 + b := bus.NewBus() 36 + cli.DataDir = t.TempDir() 37 + mod, err := model.MakeDB(":memory:") 38 + require.NoError(t, err) 39 + state, err := statedb.MakeDB(context.Background(), &cli, nil, mod) 40 + require.NoError(t, err) 41 + atsync := &ATProtoSynchronizer{ 42 + CLI: &cli, 43 + StatefulDB: state, 44 + Model: mod, 45 + Bus: b, 46 + } 47 + 48 + ctx, cancel := context.WithCancel(context.Background()) 49 + 50 + done := make(chan struct{}) 51 + 52 + go func() { 53 + err := atsync.StartFirehose(ctx) 54 + require.NoError(t, err) 55 + close(done) 56 + }() 57 + 58 + streamer := dev.CreateAccount(t) 59 + moderator := dev.CreateAccount(t) 60 + user := dev.CreateAccount(t) 61 + 62 + // Test 1: Create delegation record with expiration time 63 + t.Log("Test 1: Creating delegation record with expiration time") 64 + expirationTime := time.Now().Add(24 * time.Hour).Format(time.RFC3339) 65 + delegationRecord := &streamplace.ModerationPermission{ 66 + LexiconTypeID: "place.stream.moderation.permission", 67 + Moderator: moderator.DID, 68 + Permissions: []string{"ban", "hide", "livestream.manage"}, 69 + CreatedAt: time.Now().Format(util.ISO8601), 70 + ExpirationTime: &expirationTime, 71 + } 72 + 73 + _, err = comatproto.RepoCreateRecord(ctx, streamer.XRPC, &comatproto.RepoCreateRecord_Input{ 74 + Collection: constants.PLACE_STREAM_MODERATION_PERMISSION, 75 + Repo: streamer.DID, 76 + Record: &lexutil.LexiconTypeDecoder{Val: delegationRecord}, 77 + }) 78 + require.NoError(t, err) 79 + 80 + // Wait for firehose ingestion 81 + err = untilNoErrors(t, func() error { 82 + delegation, err := mod.GetModerationDelegation(ctx, streamer.DID, moderator.DID) 83 + if err != nil { 84 + return err 85 + } 86 + if delegation == nil { 87 + return fmt.Errorf("delegation not found") 88 + } 89 + return nil 90 + }) 91 + require.NoError(t, err) 92 + t.Log("✓ Delegation record ingested successfully") 93 + 94 + // Verify delegation details including expiration time 95 + view, err := mod.GetModerationDelegation(ctx, streamer.DID, moderator.DID) 96 + require.NoError(t, err) 97 + require.NotNil(t, view) 98 + require.Equal(t, streamer.DID, view.Author.Did) 99 + 100 + delegation := view.Record.Val.(*streamplace.ModerationPermission) 101 + require.NotNil(t, delegation) 102 + require.Equal(t, moderator.DID, delegation.Moderator) 103 + require.NotNil(t, delegation.ExpirationTime, "expiration time should be set") 104 + 105 + exp, err := time.Parse(time.RFC3339, *delegation.ExpirationTime) 106 + require.NoError(t, err) 107 + require.True(t, exp.After(time.Now()), "expiration time should be in the future") 108 + t.Log("✓ Delegation details verified (including expiration time)") 109 + 110 + // Test 2: Create block (ban user) 111 + t.Log("Test 2: Creating block record") 112 + block := &bsky.GraphBlock{ 113 + Subject: user.DID, 114 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 115 + } 116 + 117 + blockRec, err := comatproto.RepoCreateRecord(ctx, streamer.XRPC, &comatproto.RepoCreateRecord_Input{ 118 + Collection: constants.APP_BSKY_GRAPH_BLOCK, 119 + Repo: streamer.DID, 120 + Record: &lexutil.LexiconTypeDecoder{Val: block}, 121 + }) 122 + require.NoError(t, err) 123 + t.Logf("✓ Block record created: %s", blockRec.Uri) 124 + 125 + // Wait for firehose to process block 126 + blockRkey := strings.TrimPrefix(blockRec.Uri, fmt.Sprintf("at://%s/%s/", streamer.DID, constants.APP_BSKY_GRAPH_BLOCK)) 127 + err = untilNoErrors(t, func() error { 128 + block, err := mod.GetBlock(ctx, blockRkey) 129 + if err != nil { 130 + return err 131 + } 132 + if block == nil { 133 + return fmt.Errorf("block not found") 134 + } 135 + return nil 136 + }) 137 + require.NoError(t, err) 138 + t.Log("✓ Block record ingested successfully") 139 + 140 + // Test 3: Create chat message and gate 141 + t.Log("Test 3: Creating chat message and gate") 142 + msg := &streamplace.ChatMessage{ 143 + LexiconTypeID: "place.stream.chat.message", 144 + Text: "Test message to be hidden", 145 + CreatedAt: time.Now().Format(util.ISO8601), 146 + Streamer: streamer.DID, 147 + } 148 + 149 + msgRec, err := comatproto.RepoCreateRecord(ctx, user.XRPC, &comatproto.RepoCreateRecord_Input{ 150 + Collection: constants.PLACE_STREAM_CHAT_MESSAGE, 151 + Repo: user.DID, 152 + Record: &lexutil.LexiconTypeDecoder{Val: msg}, 153 + }) 154 + require.NoError(t, err) 155 + t.Logf("✓ Chat message created: %s", msgRec.Uri) 156 + 157 + // Create gate to hide the message 158 + gate := &streamplace.ChatGate{ 159 + LexiconTypeID: "place.stream.chat.gate", 160 + HiddenMessage: msgRec.Uri, 161 + } 162 + 163 + gateRec, err := comatproto.RepoCreateRecord(ctx, streamer.XRPC, &comatproto.RepoCreateRecord_Input{ 164 + Collection: constants.PLACE_STREAM_CHAT_GATE, 165 + Repo: streamer.DID, 166 + Record: &lexutil.LexiconTypeDecoder{Val: gate}, 167 + }) 168 + require.NoError(t, err) 169 + t.Logf("✓ Gate record created: %s", gateRec.Uri) 170 + 171 + // Wait for firehose to process gate 172 + gateRkey := strings.TrimPrefix(gateRec.Uri, fmt.Sprintf("at://%s/%s/", streamer.DID, constants.PLACE_STREAM_CHAT_GATE)) 173 + err = untilNoErrors(t, func() error { 174 + gate, err := mod.GetGate(ctx, gateRkey) 175 + if err != nil { 176 + return err 177 + } 178 + if gate == nil { 179 + return fmt.Errorf("gate not found") 180 + } 181 + return nil 182 + }) 183 + require.NoError(t, err) 184 + t.Log("✓ Gate record ingested successfully") 185 + 186 + // Test 4: Delete gate (unhide message) 187 + t.Log("Test 4: Deleting gate record") 188 + 189 + _, err = comatproto.RepoDeleteRecord(ctx, streamer.XRPC, &comatproto.RepoDeleteRecord_Input{ 190 + Collection: constants.PLACE_STREAM_CHAT_GATE, 191 + Repo: streamer.DID, 192 + Rkey: gateRkey, 193 + }) 194 + require.NoError(t, err) 195 + 196 + // Wait for firehose to process deletion 197 + err = untilNoErrors(t, func() error { 198 + gate, err := mod.GetGate(ctx, gateRkey) 199 + if err != nil { 200 + return err 201 + } 202 + if gate != nil { 203 + return fmt.Errorf("gate still exists") 204 + } 205 + return nil 206 + }) 207 + require.NoError(t, err) 208 + t.Log("✓ Gate record deleted successfully") 209 + 210 + // Test 5: Delete delegation (revoke permissions) 211 + t.Log("Test 5: Deleting delegation record") 212 + uri, err := syntax.ParseATURI(view.Uri) 213 + require.NoError(t, err) 214 + 215 + _, err = comatproto.RepoDeleteRecord(ctx, streamer.XRPC, &comatproto.RepoDeleteRecord_Input{ 216 + Collection: constants.PLACE_STREAM_MODERATION_PERMISSION, 217 + Repo: streamer.DID, 218 + Rkey: uri.RecordKey().String(), 219 + }) 220 + require.NoError(t, err) 221 + 222 + // Wait for firehose to process deletion 223 + err = untilNoErrors(t, func() error { 224 + delegation, err := mod.GetModerationDelegation(ctx, streamer.DID, moderator.DID) 225 + if err != nil { 226 + return err 227 + } 228 + if delegation != nil { 229 + return fmt.Errorf("delegation still exists") 230 + } 231 + return nil 232 + }) 233 + require.NoError(t, err) 234 + t.Log("✓ Delegation record deleted successfully") 235 + 236 + t.Log("All moderation tests passed!") 237 + cancel() 238 + <-done 239 + }
+25
pkg/atproto/sync.go
··· 424 424 log.Error(ctx, "failed to create metadata configuration", "err", err) 425 425 } 426 426 427 + case *streamplace.ModerationPermission: 428 + repo, err := atsync.SyncBlueskyRepoCached(ctx, userDID, atsync.Model) 429 + if err != nil { 430 + return fmt.Errorf("failed to sync bluesky repo: %w", err) 431 + } 432 + log.Debug(ctx, "creating moderation delegation", "streamerDID", userDID, "moderatorDID", rec.Moderator) 433 + 434 + err = atsync.Model.CreateModerationDelegation(ctx, rec, aturi) 435 + if err != nil { 436 + return fmt.Errorf("failed to create moderation delegation: %w", err) 437 + } 438 + 439 + view := &streamplace.ModerationDefs_PermissionView{ 440 + Uri: aturi.String(), 441 + Cid: cid, 442 + Author: &bsky.ActorDefs_ProfileViewBasic{ 443 + Did: userDID, 444 + Handle: repo.Handle, 445 + }, 446 + Record: &lexutil.LexiconTypeDecoder{Val: rec}, 447 + } 448 + // Publish moderation permission view to WebSocket bus for real-time updates 449 + // This allows moderators to see their permissions instantly without page refresh 450 + go atsync.Bus.Publish(userDID, view) 451 + 427 452 case *streamplace.LiveRecommendations: 428 453 log.Debug(ctx, "creating recommendations", "userDID", userDID, "count", len(rec.Streamers)) 429 454
+13 -12
pkg/constants/constants.go
··· 1 1 package constants 2 2 3 - var PLACE_STREAM_KEY = "place.stream.key" //nolint:all 4 - var PLACE_STREAM_LIVESTREAM = "place.stream.livestream" //nolint:all 5 - var PLACE_STREAM_CHAT_MESSAGE = "place.stream.chat.message" //nolint:all 6 - var PLACE_STREAM_CHAT_PROFILE = "place.stream.chat.profile" //nolint:all 7 - var PLACE_STREAM_SERVER_SETTINGS = "place.stream.server.settings" //nolint:all 8 - var STREAMPLACE_SIGNING_KEY = "signingKey" //nolint:all 9 - var APP_BSKY_GRAPH_FOLLOW = "app.bsky.graph.follow" //nolint:all 10 - var APP_BSKY_FEED_POST = "app.bsky.feed.post" //nolint:all 11 - var APP_BSKY_GRAPH_BLOCK = "app.bsky.graph.block" //nolint:all 12 - var PLACE_STREAM_CHAT_GATE = "place.stream.chat.gate" //nolint:all 13 - var PLACE_STREAM_DEFAULT_METADATA = "place.stream.metadata.configuration" //nolint:all 14 - var PLACE_STREAM_LIVE_RECOMMENDATIONS = "place.stream.live.recommendations" //nolint:all 3 + var PLACE_STREAM_KEY = "place.stream.key" //nolint:all 4 + var PLACE_STREAM_LIVESTREAM = "place.stream.livestream" //nolint:all 5 + var PLACE_STREAM_CHAT_MESSAGE = "place.stream.chat.message" //nolint:all 6 + var PLACE_STREAM_CHAT_PROFILE = "place.stream.chat.profile" //nolint:all 7 + var PLACE_STREAM_SERVER_SETTINGS = "place.stream.server.settings" //nolint:all 8 + var PLACE_STREAM_MODERATION_PERMISSION = "place.stream.moderation.permission" //nolint:all 9 + var STREAMPLACE_SIGNING_KEY = "signingKey" //nolint:all 10 + var APP_BSKY_GRAPH_FOLLOW = "app.bsky.graph.follow" //nolint:all 11 + var APP_BSKY_FEED_POST = "app.bsky.feed.post" //nolint:all 12 + var APP_BSKY_GRAPH_BLOCK = "app.bsky.graph.block" //nolint:all 13 + var PLACE_STREAM_CHAT_GATE = "place.stream.chat.gate" //nolint:all 14 + var PLACE_STREAM_DEFAULT_METADATA = "place.stream.metadata.configuration" //nolint:all 15 + var PLACE_STREAM_LIVE_RECOMMENDATIONS = "place.stream.live.recommendations" //nolint:all 15 16 16 17 const DID_KEY_PREFIX = "did:key" //nolint:all 17 18 const ADDRESS_KEY_PREFIX = "0x" //nolint:all
+1
pkg/gen/gen.go
··· 32 32 streamplace.MetadataDistributionPolicy{}, 33 33 streamplace.MetadataContentRights{}, 34 34 streamplace.MetadataContentWarnings{}, 35 + streamplace.ModerationPermission{}, 35 36 streamplace.LiveRecommendations{}, 36 37 ); err != nil { 37 38 panic(err)
+8
pkg/model/model.go
··· 110 110 GetMetadataConfiguration(ctx context.Context, repoDID string) (*MetadataConfiguration, error) 111 111 DeleteMetadataConfiguration(ctx context.Context, repoDID string) error 112 112 113 + CreateModerationDelegation(ctx context.Context, rec *streamplace.ModerationPermission, aturi syntax.ATURI) error 114 + DeleteModerationDelegation(ctx context.Context, rkey string) error 115 + GetModerationDelegation(ctx context.Context, streamerDID, moderatorDID string) (*streamplace.ModerationDefs_PermissionView, error) 116 + GetModerationDelegations(ctx context.Context, streamerDID, moderatorDID string) ([]*streamplace.ModerationDefs_PermissionView, error) 117 + GetModeratorDelegations(ctx context.Context, moderatorDID string) ([]*streamplace.ModerationDefs_PermissionView, error) 118 + GetStreamerModerators(ctx context.Context, streamerDID string) ([]*streamplace.ModerationDefs_PermissionView, error) 119 + 113 120 GetRecommendation(userDID string) (*Recommendation, error) 114 121 UpsertRecommendation(rec *Recommendation) error 115 122 } ··· 180 187 Label{}, 181 188 BroadcastOrigin{}, 182 189 MetadataConfiguration{}, 190 + ModerationDelegation{}, 183 191 Recommendation{}, 184 192 } { 185 193 err = db.AutoMigrate(model)
+165
pkg/model/moderation_delegation.go
··· 1 + package model 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "errors" 7 + "fmt" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/api/bsky" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + lexutil "github.com/bluesky-social/indigo/lex/util" 13 + "gorm.io/gorm" 14 + "stream.place/streamplace/pkg/aqtime" 15 + "stream.place/streamplace/pkg/spid" 16 + "stream.place/streamplace/pkg/streamplace" 17 + ) 18 + 19 + type ModerationDelegation struct { 20 + RKey string `gorm:"primaryKey;column:rkey"` 21 + CID string `gorm:"column:cid"` 22 + RepoDID string `json:"repoDID" gorm:"column:repo_did;index:idx_repo_moderator,priority:1"` 23 + Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"` 24 + ModeratorDID string `gorm:"column:moderator_did;index:idx_repo_moderator,priority:2;index:idx_moderator"` 25 + Record []byte `gorm:"column:record"` // Full CBOR record 26 + CreatedAt time.Time `gorm:"column:created_at"` 27 + IndexedAt time.Time `gorm:"column:indexed_at"` 28 + } 29 + 30 + func (md *ModerationDelegation) ToPermissionView() (*streamplace.ModerationDefs_PermissionView, error) { 31 + rec, err := lexutil.CborDecodeValue(md.Record) 32 + if err != nil { 33 + return nil, fmt.Errorf("error decoding moderation permission: %w", err) 34 + } 35 + 36 + uri := fmt.Sprintf("at://%s/place.stream.moderation.permission/%s", md.RepoDID, md.RKey) 37 + 38 + view := &streamplace.ModerationDefs_PermissionView{ 39 + Author: &bsky.ActorDefs_ProfileViewBasic{ 40 + Did: md.RepoDID, 41 + }, 42 + Cid: md.CID, 43 + Record: &lexutil.LexiconTypeDecoder{Val: rec}, 44 + Uri: uri, 45 + } 46 + 47 + if md.Repo != nil { 48 + view.Author.Handle = md.Repo.Handle 49 + } 50 + 51 + return view, nil 52 + } 53 + 54 + func (m *DBModel) CreateModerationDelegation(ctx context.Context, rec *streamplace.ModerationPermission, aturi syntax.ATURI) error { 55 + repoDID, err := aturi.Authority().AsDID() 56 + if err != nil { 57 + return fmt.Errorf("invalid ATURI authority: %w", err) 58 + } 59 + cid, err := spid.GetCID(rec) 60 + if err != nil { 61 + return fmt.Errorf("failed to get CID: %w", err) 62 + } 63 + rkey := aturi.RecordKey().String() 64 + 65 + buf := bytes.Buffer{} 66 + err = rec.MarshalCBOR(&buf) 67 + if err != nil { 68 + return fmt.Errorf("failed to marshal moderation permission: %w", err) 69 + } 70 + 71 + now := aqtime.FromTime(time.Now().UTC()) 72 + 73 + delegation := &ModerationDelegation{ 74 + RKey: rkey, 75 + CID: cid.String(), 76 + RepoDID: repoDID.String(), 77 + ModeratorDID: rec.Moderator, 78 + Record: buf.Bytes(), 79 + CreatedAt: now.Time().UTC(), 80 + IndexedAt: now.Time().UTC(), 81 + } 82 + 83 + return m.DB.WithContext(ctx).Create(delegation).Error 84 + } 85 + 86 + func (m *DBModel) DeleteModerationDelegation(ctx context.Context, rkey string) error { 87 + return m.DB.WithContext(ctx).Where("rkey = ?", rkey).Delete(&ModerationDelegation{}).Error 88 + } 89 + 90 + func (m *DBModel) GetModerationDelegation(ctx context.Context, streamerDID, moderatorDID string) (*streamplace.ModerationDefs_PermissionView, error) { 91 + var delegation ModerationDelegation 92 + err := m.DB.WithContext(ctx).Preload("Repo"). 93 + Where("repo_did = ? AND moderator_did = ?", streamerDID, moderatorDID). 94 + Order("created_at DESC"). 95 + First(&delegation).Error 96 + if errors.Is(err, gorm.ErrRecordNotFound) { 97 + return nil, nil 98 + } 99 + if err != nil { 100 + return nil, err 101 + } 102 + return delegation.ToPermissionView() 103 + } 104 + 105 + // GetModerationDelegations returns ALL delegation records for a moderator from a specific streamer. 106 + // This allows multiple separate permission records (e.g., one for "ban", one for "hide") to be merged. 107 + func (m *DBModel) GetModerationDelegations(ctx context.Context, streamerDID, moderatorDID string) ([]*streamplace.ModerationDefs_PermissionView, error) { 108 + var delegations []*ModerationDelegation 109 + err := m.DB.WithContext(ctx).Preload("Repo"). 110 + Where("repo_did = ? AND moderator_did = ?", streamerDID, moderatorDID). 111 + Find(&delegations).Error 112 + if err != nil { 113 + return nil, err 114 + } 115 + 116 + views := make([]*streamplace.ModerationDefs_PermissionView, len(delegations)) 117 + for i, d := range delegations { 118 + view, err := d.ToPermissionView() 119 + if err != nil { 120 + return nil, err 121 + } 122 + views[i] = view 123 + } 124 + return views, nil 125 + } 126 + 127 + func (m *DBModel) GetModeratorDelegations(ctx context.Context, moderatorDID string) ([]*streamplace.ModerationDefs_PermissionView, error) { 128 + var delegations []*ModerationDelegation 129 + err := m.DB.WithContext(ctx).Preload("Repo"). 130 + Where("moderator_did = ?", moderatorDID). 131 + Find(&delegations).Error 132 + if err != nil { 133 + return nil, err 134 + } 135 + 136 + views := make([]*streamplace.ModerationDefs_PermissionView, len(delegations)) 137 + for i, d := range delegations { 138 + view, err := d.ToPermissionView() 139 + if err != nil { 140 + return nil, err 141 + } 142 + views[i] = view 143 + } 144 + return views, nil 145 + } 146 + 147 + func (m *DBModel) GetStreamerModerators(ctx context.Context, streamerDID string) ([]*streamplace.ModerationDefs_PermissionView, error) { 148 + var delegations []*ModerationDelegation 149 + err := m.DB.WithContext(ctx).Preload("Repo"). 150 + Where("repo_did = ?", streamerDID). 151 + Find(&delegations).Error 152 + if err != nil { 153 + return nil, err 154 + } 155 + 156 + views := make([]*streamplace.ModerationDefs_PermissionView, len(delegations)) 157 + for i, d := range delegations { 158 + view, err := d.ToPermissionView() 159 + if err != nil { 160 + return nil, err 161 + } 162 + views[i] = view 163 + } 164 + return views, nil 165 + }
+114
pkg/moderation/permissions.go
··· 1 + package moderation 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "time" 7 + 8 + "stream.place/streamplace/pkg/streamplace" 9 + ) 10 + 11 + type delegationGetter interface { 12 + GetModerationDelegations(ctx context.Context, streamerDID, moderatorDID string) ([]*streamplace.ModerationDefs_PermissionView, error) 13 + } 14 + 15 + // Permission scope constants 16 + const ( 17 + PermissionBan = "ban" 18 + PermissionHide = "hide" 19 + PermissionLivestreamManage = "livestream.manage" 20 + ) 21 + 22 + // ActionPermissions maps moderation actions to required permissions 23 + var ActionPermissions = map[string]string{ 24 + "createBlock": PermissionBan, 25 + "deleteBlock": PermissionBan, 26 + "createGate": PermissionHide, 27 + "deleteGate": PermissionHide, 28 + "updateLivestream": PermissionLivestreamManage, 29 + } 30 + 31 + // PermissionChecker validates moderation permissions 32 + type PermissionChecker struct { 33 + model delegationGetter 34 + } 35 + 36 + // NewPermissionChecker creates a new permission checker 37 + func NewPermissionChecker(m delegationGetter) *PermissionChecker { 38 + return &PermissionChecker{model: m} 39 + } 40 + 41 + // CheckPermission validates that a moderator has permission to perform an action 42 + // Returns an error if the moderator lacks the required permission 43 + func (pc *PermissionChecker) CheckPermission(ctx context.Context, moderatorDID, streamerDID, action string) error { 44 + // Get required permission for this action (validate action first) 45 + requiredPermission, ok := ActionPermissions[action] 46 + if !ok { 47 + return fmt.Errorf("unknown action: %s", action) 48 + } 49 + 50 + // Streamers always have permission for their own content 51 + if moderatorDID == streamerDID { 52 + return nil 53 + } 54 + 55 + // Check if moderator has the required permission 56 + hasPermission, err := pc.HasPermission(ctx, moderatorDID, streamerDID, requiredPermission) 57 + if err != nil { 58 + return fmt.Errorf("failed to check permissions: %w", err) 59 + } 60 + 61 + if !hasPermission { 62 + return fmt.Errorf("moderator %s does not have permission '%s' for streamer %s", moderatorDID, requiredPermission, streamerDID) 63 + } 64 + 65 + return nil 66 + } 67 + 68 + // HasPermission checks if a moderator has a specific permission for a streamer. 69 + // It merges permissions from ALL delegation records for the moderator. 70 + func (pc *PermissionChecker) HasPermission(ctx context.Context, moderatorDID, streamerDID, permission string) (bool, error) { 71 + // Streamers always have all permissions for their own content 72 + if moderatorDID == streamerDID { 73 + return true, nil 74 + } 75 + 76 + // Look up ALL delegation records for this moderator 77 + delegations, err := pc.model.GetModerationDelegations(ctx, streamerDID, moderatorDID) 78 + if err != nil { 79 + return false, fmt.Errorf("failed to get moderation delegations: %w", err) 80 + } 81 + 82 + if len(delegations) == 0 { 83 + return false, nil 84 + } 85 + 86 + // Check all delegation records and merge their permissions 87 + for _, delegationView := range delegations { 88 + // Extract the actual permission record from the view 89 + permRecord, ok := delegationView.Record.Val.(*streamplace.ModerationPermission) 90 + if !ok { 91 + return false, fmt.Errorf("failed to cast record to ModerationPermission") 92 + } 93 + 94 + // Skip expired delegations 95 + if permRecord.ExpirationTime != nil { 96 + expirationTime, err := time.Parse(time.RFC3339, *permRecord.ExpirationTime) 97 + if err != nil { 98 + return false, fmt.Errorf("failed to parse expiration time: %w", err) 99 + } 100 + if time.Now().After(expirationTime) { 101 + continue 102 + } 103 + } 104 + 105 + // Check if this delegation has the required permission 106 + for _, p := range permRecord.Permissions { 107 + if p == permission { 108 + return true, nil 109 + } 110 + } 111 + } 112 + 113 + return false, nil 114 + }
+243
pkg/moderation/permissions_test.go
··· 1 + package moderation 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "testing" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/api/bsky" 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + "github.com/stretchr/testify/require" 12 + "stream.place/streamplace/pkg/streamplace" 13 + ) 14 + 15 + func TestPermissionChecker_CheckPermission_StreamerSelfModeration(t *testing.T) { 16 + mod := newMockModel() 17 + pc := NewPermissionChecker(mod) 18 + 19 + streamerDID := "did:plc:streamer123" 20 + 21 + err := pc.CheckPermission(context.Background(), streamerDID, streamerDID, "createBlock") 22 + require.NoError(t, err, "streamer should have permission for self-moderation") 23 + 24 + err = pc.CheckPermission(context.Background(), streamerDID, streamerDID, "createGate") 25 + require.NoError(t, err, "streamer should have permission for self-moderation") 26 + 27 + err = pc.CheckPermission(context.Background(), streamerDID, streamerDID, "updateLivestream") 28 + require.NoError(t, err, "streamer should have permission for self-moderation") 29 + } 30 + 31 + func TestPermissionChecker_CheckPermission_WithCorrectPermission(t *testing.T) { 32 + mod := newMockModel() 33 + pc := NewPermissionChecker(mod) 34 + 35 + ctx := context.Background() 36 + streamerDID := "did:plc:streamer123" 37 + moderatorDID := "did:plc:moderator456" 38 + 39 + mod.addPermissionView(streamerDID, moderatorDID, []string{"ban", "hide"}, nil) 40 + 41 + err := pc.CheckPermission(ctx, moderatorDID, streamerDID, "createBlock") 42 + require.NoError(t, err, "moderator with 'ban' permission should be able to createBlock") 43 + 44 + err = pc.CheckPermission(ctx, moderatorDID, streamerDID, "createGate") 45 + require.NoError(t, err, "moderator with 'hide' permission should be able to createGate") 46 + } 47 + 48 + func TestPermissionChecker_CheckPermission_WithWrongPermission(t *testing.T) { 49 + mod := newMockModel() 50 + pc := NewPermissionChecker(mod) 51 + 52 + ctx := context.Background() 53 + streamerDID := "did:plc:streamer123" 54 + moderatorDID := "did:plc:moderator456" 55 + 56 + mod.addPermissionView(streamerDID, moderatorDID, []string{"hide"}, nil) 57 + 58 + err := pc.CheckPermission(ctx, moderatorDID, streamerDID, "createBlock") 59 + require.Error(t, err, "moderator with only 'hide' permission should not be able to createBlock") 60 + require.Contains(t, err.Error(), "does not have permission 'ban'") 61 + } 62 + 63 + func TestPermissionChecker_CheckPermission_WithoutAnyPermission(t *testing.T) { 64 + mod := newMockModel() 65 + pc := NewPermissionChecker(mod) 66 + 67 + ctx := context.Background() 68 + streamerDID := "did:plc:streamer123" 69 + moderatorDID := "did:plc:moderator456" 70 + 71 + err := pc.CheckPermission(ctx, moderatorDID, streamerDID, "createBlock") 72 + require.Error(t, err, "moderator without any delegation should be denied") 73 + require.Contains(t, err.Error(), "does not have permission") 74 + } 75 + 76 + func TestPermissionChecker_CheckPermission_UnknownAction(t *testing.T) { 77 + mod := newMockModel() 78 + pc := NewPermissionChecker(mod) 79 + 80 + streamerDID := "did:plc:streamer123" 81 + 82 + err := pc.CheckPermission(context.Background(), streamerDID, streamerDID, "unknownAction") 83 + require.Error(t, err) 84 + require.Contains(t, err.Error(), "unknown action") 85 + } 86 + 87 + func TestPermissionChecker_HasPermission(t *testing.T) { 88 + mod := newMockModel() 89 + pc := NewPermissionChecker(mod) 90 + 91 + ctx := context.Background() 92 + streamerDID := "did:plc:streamer123" 93 + moderatorDID := "did:plc:moderator456" 94 + 95 + mod.addPermissionView(streamerDID, moderatorDID, []string{"ban", "hide"}, nil) 96 + 97 + has, err := pc.HasPermission(ctx, moderatorDID, streamerDID, PermissionBan) 98 + require.NoError(t, err) 99 + require.True(t, has, "should have 'ban' permission") 100 + 101 + has, err = pc.HasPermission(ctx, moderatorDID, streamerDID, PermissionHide) 102 + require.NoError(t, err) 103 + require.True(t, has, "should have 'hide' permission") 104 + 105 + has, err = pc.HasPermission(ctx, moderatorDID, streamerDID, PermissionLivestreamManage) 106 + require.NoError(t, err) 107 + require.False(t, has, "should not have 'livestream.manage' permission") 108 + } 109 + 110 + func TestActionPermissions_Mapping(t *testing.T) { 111 + require.Equal(t, PermissionBan, ActionPermissions["createBlock"]) 112 + require.Equal(t, PermissionBan, ActionPermissions["deleteBlock"]) 113 + require.Equal(t, PermissionHide, ActionPermissions["createGate"]) 114 + require.Equal(t, PermissionHide, ActionPermissions["deleteGate"]) 115 + require.Equal(t, PermissionLivestreamManage, ActionPermissions["updateLivestream"]) 116 + } 117 + 118 + func TestPermissionChecker_HasPermission_MultipleSeparateRecords(t *testing.T) { 119 + mod := newMockModel() 120 + pc := NewPermissionChecker(mod) 121 + 122 + ctx := context.Background() 123 + streamerDID := "did:plc:streamer123" 124 + moderatorDID := "did:plc:moderator456" 125 + 126 + mod.addPermissionView(streamerDID, moderatorDID, []string{"ban"}, nil) 127 + mod.addPermissionView(streamerDID, moderatorDID, []string{"hide"}, nil) 128 + 129 + hasBan, err := pc.HasPermission(ctx, moderatorDID, streamerDID, PermissionBan) 130 + require.NoError(t, err) 131 + require.True(t, hasBan, "should have 'ban' permission from first record") 132 + 133 + hasHide, err := pc.HasPermission(ctx, moderatorDID, streamerDID, PermissionHide) 134 + require.NoError(t, err) 135 + require.True(t, hasHide, "should have 'hide' permission from second record") 136 + 137 + err = pc.CheckPermission(ctx, moderatorDID, streamerDID, "createBlock") 138 + require.NoError(t, err, "should allow createBlock with 'ban' permission from separate record") 139 + 140 + err = pc.CheckPermission(ctx, moderatorDID, streamerDID, "createGate") 141 + require.NoError(t, err, "should allow createGate with 'hide' permission from separate record") 142 + } 143 + 144 + func TestPermissionChecker_HasPermission_ExpiredDelegation(t *testing.T) { 145 + mod := newMockModel() 146 + pc := NewPermissionChecker(mod) 147 + 148 + ctx := context.Background() 149 + streamerDID := "did:plc:streamer123" 150 + moderatorDID := "did:plc:moderator456" 151 + 152 + expiredTime := time.Now().Add(-1 * time.Hour) 153 + mod.addPermissionView(streamerDID, moderatorDID, []string{"ban"}, &expiredTime) 154 + 155 + hasPermission, err := pc.HasPermission(ctx, moderatorDID, streamerDID, PermissionBan) 156 + require.NoError(t, err) 157 + require.False(t, hasPermission, "should deny permission for expired delegation") 158 + 159 + err = pc.CheckPermission(ctx, moderatorDID, streamerDID, "createBlock") 160 + require.Error(t, err, "should deny action for expired delegation") 161 + require.Contains(t, err.Error(), "does not have permission") 162 + } 163 + 164 + func TestPermissionChecker_HasPermission_NotYetExpired(t *testing.T) { 165 + mod := newMockModel() 166 + pc := NewPermissionChecker(mod) 167 + 168 + ctx := context.Background() 169 + streamerDID := "did:plc:streamer123" 170 + moderatorDID := "did:plc:moderator456" 171 + 172 + futureTime := time.Now().Add(1 * time.Hour) 173 + mod.addPermissionView(streamerDID, moderatorDID, []string{"ban", "hide"}, &futureTime) 174 + 175 + hasPermission, err := pc.HasPermission(ctx, moderatorDID, streamerDID, PermissionBan) 176 + require.NoError(t, err) 177 + require.True(t, hasPermission, "should allow permission for not-yet-expired delegation") 178 + 179 + err = pc.CheckPermission(ctx, moderatorDID, streamerDID, "createBlock") 180 + require.NoError(t, err, "should allow action for not-yet-expired delegation") 181 + } 182 + 183 + func TestPermissionChecker_HasPermission_NoExpiration(t *testing.T) { 184 + mod := newMockModel() 185 + pc := NewPermissionChecker(mod) 186 + 187 + ctx := context.Background() 188 + streamerDID := "did:plc:streamer123" 189 + moderatorDID := "did:plc:moderator456" 190 + 191 + mod.addPermissionView(streamerDID, moderatorDID, []string{"ban", "hide"}, nil) 192 + 193 + hasPermission, err := pc.HasPermission(ctx, moderatorDID, streamerDID, PermissionBan) 194 + require.NoError(t, err) 195 + require.True(t, hasPermission, "should allow permission for delegation with no expiration") 196 + 197 + err = pc.CheckPermission(ctx, moderatorDID, streamerDID, "createBlock") 198 + require.NoError(t, err, "should allow action for delegation with no expiration") 199 + } 200 + 201 + type mockModel struct { 202 + delegations map[string][]*streamplace.ModerationDefs_PermissionView 203 + } 204 + 205 + func newMockModel() *mockModel { 206 + return &mockModel{ 207 + delegations: make(map[string][]*streamplace.ModerationDefs_PermissionView), 208 + } 209 + } 210 + 211 + func (m *mockModel) addPermissionView(streamerDID, moderatorDID string, permissions []string, expirationTime *time.Time) { 212 + key := streamerDID + "_" + moderatorDID 213 + 214 + var expTimeStr *string 215 + if expirationTime != nil { 216 + str := expirationTime.Format(time.RFC3339) 217 + expTimeStr = &str 218 + } 219 + 220 + permRecord := &streamplace.ModerationPermission{ 221 + Moderator: moderatorDID, 222 + Permissions: permissions, 223 + ExpirationTime: expTimeStr, 224 + } 225 + 226 + view := &streamplace.ModerationDefs_PermissionView{ 227 + Uri: fmt.Sprintf("at://%s/place.stream.moderation.permission/test", streamerDID), 228 + Cid: "bafytest", 229 + Author: &bsky.ActorDefs_ProfileViewBasic{Did: streamerDID}, 230 + Record: &lexutil.LexiconTypeDecoder{Val: permRecord}, 231 + } 232 + 233 + m.delegations[key] = append(m.delegations[key], view) 234 + } 235 + 236 + func (m *mockModel) GetModerationDelegations(ctx context.Context, streamerDID, moderatorDID string) ([]*streamplace.ModerationDefs_PermissionView, error) { 237 + key := streamerDID + "_" + moderatorDID 238 + delegations, exists := m.delegations[key] 239 + if !exists { 240 + return nil, nil 241 + } 242 + return delegations, nil 243 + }
+78
pkg/spxrpc/moderation_helpers.go
··· 1 + package spxrpc 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + 8 + "github.com/labstack/echo/v4" 9 + "github.com/streamplace/oatproxy/pkg/oatproxy" 10 + "stream.place/streamplace/pkg/log" 11 + "stream.place/streamplace/pkg/moderation" 12 + ) 13 + 14 + // DelegatedModerationContext contains validated session and client for delegated moderation actions 15 + type DelegatedModerationContext struct { 16 + ModeratorDID string 17 + ModeratorSession *oatproxy.OAuthSession 18 + StreamerClient *oatproxy.XrpcClient 19 + StreamerSession *oatproxy.OAuthSession 20 + } 21 + 22 + // GetDelegatedModerationContext validates moderator OAuth, checks permission, and returns streamer client 23 + // This consolidates the repeated pattern in all moderation handlers to eliminate duplication and fix security issues 24 + func (s *Server) GetDelegatedModerationContext( 25 + ctx context.Context, 26 + streamerDID string, 27 + action string, 28 + ) (*DelegatedModerationContext, error) { 29 + 30 + // Step 1: Get and validate moderator OAuth session 31 + // NOTE: GetOAuthSession returns (*OAuthSession, *XrpcClient), not (*OAuthSession, error) 32 + moderatorSession, _ := oatproxy.GetOAuthSession(ctx) 33 + if moderatorSession == nil { 34 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") 35 + } 36 + moderatorDID := moderatorSession.DID 37 + 38 + // Step 2: Check permission 39 + permChecker := moderation.NewPermissionChecker(s.model) 40 + if err := permChecker.CheckPermission(ctx, moderatorDID, streamerDID, action); err != nil { 41 + log.Warn(ctx, "permission denied", "moderator", moderatorDID, "streamer", streamerDID, "action", action, "error", err) 42 + return nil, echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("permission denied: %v", err)) 43 + } 44 + 45 + // Step 3: Get streamer session 46 + streamerSession, err := s.statefulDB.GetSessionByDID(streamerDID) 47 + if err != nil { 48 + log.Error(ctx, "failed to get streamer session", "streamer", streamerDID, "error", err) 49 + return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get streamer session: %v", err)) 50 + } 51 + if streamerSession == nil { 52 + return nil, echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("session not found for streamer %s", streamerDID)) 53 + } 54 + 55 + // Step 4: Refresh session if needed 56 + streamerSession, err = s.op.RefreshIfNeeded(streamerSession) 57 + if err != nil { 58 + log.Error(ctx, "failed to refresh streamer session", "streamer", streamerDID, "error", err) 59 + return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to refresh session: %v", err)) 60 + } 61 + if streamerSession == nil { 62 + return nil, echo.NewHTTPError(http.StatusNotFound, "streamer session not found after refresh") 63 + } 64 + 65 + // Step 5: Get XRPC client for streamer 66 + client, err := s.op.GetXrpcClient(streamerSession) 67 + if err != nil { 68 + log.Error(ctx, "failed to get xrpc client", "streamer", streamerDID, "error", err) 69 + return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get xrpc client: %v", err)) 70 + } 71 + 72 + return &DelegatedModerationContext{ 73 + ModeratorDID: moderatorDID, 74 + ModeratorSession: moderatorSession, 75 + StreamerClient: client, 76 + StreamerSession: streamerSession, 77 + }, nil 78 + }
+362
pkg/spxrpc/place_stream_moderation.go
··· 1 + package spxrpc 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "time" 9 + 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/api/bsky" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + lexutil "github.com/bluesky-social/indigo/lex/util" 14 + "github.com/bluesky-social/indigo/xrpc" 15 + "github.com/labstack/echo/v4" 16 + "stream.place/streamplace/pkg/constants" 17 + "stream.place/streamplace/pkg/log" 18 + "stream.place/streamplace/pkg/statedb" 19 + "stream.place/streamplace/pkg/streamplace" 20 + ) 21 + 22 + // handlePlaceStreamModerationCreateBlock creates a block (ban) on behalf of a streamer 23 + func (s *Server) handlePlaceStreamModerationCreateBlock(ctx context.Context, input *streamplace.ModerationCreateBlock_Input) (*streamplace.ModerationCreateBlock_Output, error) { 24 + // Validate input 25 + if err := validateDID(input.Streamer); err != nil { 26 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid streamer DID: %v", err)) 27 + } 28 + if err := validateDID(input.Subject); err != nil { 29 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid subject DID: %v", err)) 30 + } 31 + 32 + // Get delegated moderation context (validates OAuth, permission, and returns client) 33 + modCtx, err := s.GetDelegatedModerationContext(ctx, input.Streamer, "createBlock") 34 + if err != nil { 35 + return nil, err 36 + } 37 + 38 + // Create block record in streamer's repo 39 + block := &bsky.GraphBlock{ 40 + Subject: input.Subject, 41 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 42 + } 43 + 44 + createInput := comatproto.RepoCreateRecord_Input{ 45 + Collection: constants.APP_BSKY_GRAPH_BLOCK, 46 + Record: &lexutil.LexiconTypeDecoder{Val: block}, 47 + Repo: input.Streamer, 48 + } 49 + createOutput := comatproto.RepoCreateRecord_Output{} 50 + 51 + err = modCtx.StreamerClient.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", map[string]any{}, createInput, &createOutput) 52 + if err != nil { 53 + log.Error(ctx, "failed to create block record", "err", err) 54 + if auditErr := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "createBlock", "", input.Subject, "", false, err.Error()); auditErr != nil { 55 + log.Error(ctx, "failed to create audit log", "error", auditErr) 56 + } 57 + return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create block: %v", err)) 58 + } 59 + 60 + // Log successful audit entry 61 + if err := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "createBlock", "", input.Subject, createOutput.Uri, true, ""); err != nil { 62 + log.Error(ctx, "failed to create audit log", "error", err) 63 + } 64 + 65 + return &streamplace.ModerationCreateBlock_Output{ 66 + Uri: createOutput.Uri, 67 + Cid: createOutput.Cid, 68 + }, nil 69 + } 70 + 71 + // handlePlaceStreamModerationDeleteBlock deletes a block (unban) on behalf of a streamer 72 + func (s *Server) handlePlaceStreamModerationDeleteBlock(ctx context.Context, input *streamplace.ModerationDeleteBlock_Input) (*streamplace.ModerationDeleteBlock_Output, error) { 73 + // Validate input 74 + if err := validateDID(input.Streamer); err != nil { 75 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid streamer DID: %v", err)) 76 + } 77 + if err := validateATURI(input.BlockUri); err != nil { 78 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid block URI: %v", err)) 79 + } 80 + 81 + // Get delegated moderation context (validates OAuth, permission, and returns client) 82 + modCtx, err := s.GetDelegatedModerationContext(ctx, input.Streamer, "deleteBlock") 83 + if err != nil { 84 + return nil, err 85 + } 86 + 87 + // Parse blockUri to extract rkey 88 + // AT-URI format: at://did:plc:xxx/collection/rkey 89 + rkey, err := extractRKey(input.BlockUri) 90 + if err != nil { 91 + log.Error(ctx, "failed to extract rkey from blockUri", "uri", input.BlockUri, "err", err) 92 + return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid blockUri format") 93 + } 94 + 95 + // Delete block record from streamer's repo 96 + deleteInput := comatproto.RepoDeleteRecord_Input{ 97 + Collection: constants.APP_BSKY_GRAPH_BLOCK, 98 + Rkey: rkey, 99 + Repo: input.Streamer, 100 + } 101 + deleteOutput := comatproto.RepoDeleteRecord_Output{} 102 + 103 + err = modCtx.StreamerClient.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", map[string]any{}, deleteInput, &deleteOutput) 104 + if err != nil { 105 + log.Error(ctx, "failed to delete block record", "err", err) 106 + if auditErr := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "deleteBlock", input.BlockUri, "", "", false, err.Error()); auditErr != nil { 107 + log.Error(ctx, "failed to create audit log", "error", auditErr) 108 + } 109 + return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete block: %v", err)) 110 + } 111 + 112 + // Log successful audit entry 113 + if err := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "deleteBlock", input.BlockUri, "", "", true, ""); err != nil { 114 + log.Error(ctx, "failed to create audit log", "error", err) 115 + } 116 + 117 + return &streamplace.ModerationDeleteBlock_Output{}, nil 118 + } 119 + 120 + // handlePlaceStreamModerationCreateGate creates a gate (hide message) on behalf of a streamer 121 + func (s *Server) handlePlaceStreamModerationCreateGate(ctx context.Context, input *streamplace.ModerationCreateGate_Input) (*streamplace.ModerationCreateGate_Output, error) { 122 + // Validate input 123 + if err := validateDID(input.Streamer); err != nil { 124 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid streamer DID: %v", err)) 125 + } 126 + if err := validateATURI(input.MessageUri); err != nil { 127 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid message URI: %v", err)) 128 + } 129 + 130 + // Get delegated moderation context (validates OAuth, permission, and returns client) 131 + modCtx, err := s.GetDelegatedModerationContext(ctx, input.Streamer, "createGate") 132 + if err != nil { 133 + return nil, err 134 + } 135 + 136 + // Create gate record in streamer's repo 137 + gate := &streamplace.ChatGate{ 138 + HiddenMessage: input.MessageUri, 139 + } 140 + 141 + createInput := comatproto.RepoCreateRecord_Input{ 142 + Collection: constants.PLACE_STREAM_CHAT_GATE, 143 + Record: &lexutil.LexiconTypeDecoder{Val: gate}, 144 + Repo: input.Streamer, 145 + } 146 + createOutput := comatproto.RepoCreateRecord_Output{} 147 + 148 + err = modCtx.StreamerClient.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", map[string]any{}, createInput, &createOutput) 149 + if err != nil { 150 + log.Error(ctx, "failed to create gate record", "err", err) 151 + if auditErr := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "createGate", input.MessageUri, "", "", false, err.Error()); auditErr != nil { 152 + log.Error(ctx, "failed to create audit log", "error", auditErr) 153 + } 154 + return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create gate: %v", err)) 155 + } 156 + 157 + // Log successful audit entry 158 + if err := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "createGate", input.MessageUri, "", createOutput.Uri, true, ""); err != nil { 159 + log.Error(ctx, "failed to create audit log", "error", err) 160 + } 161 + 162 + return &streamplace.ModerationCreateGate_Output{ 163 + Uri: createOutput.Uri, 164 + Cid: createOutput.Cid, 165 + }, nil 166 + } 167 + 168 + // handlePlaceStreamModerationDeleteGate deletes a gate (unhide message) on behalf of a streamer 169 + func (s *Server) handlePlaceStreamModerationDeleteGate(ctx context.Context, input *streamplace.ModerationDeleteGate_Input) (*streamplace.ModerationDeleteGate_Output, error) { 170 + // Validate input 171 + if err := validateDID(input.Streamer); err != nil { 172 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid streamer DID: %v", err)) 173 + } 174 + if err := validateATURI(input.GateUri); err != nil { 175 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid gate URI: %v", err)) 176 + } 177 + 178 + // Get delegated moderation context (validates OAuth, permission, and returns client) 179 + modCtx, err := s.GetDelegatedModerationContext(ctx, input.Streamer, "deleteGate") 180 + if err != nil { 181 + return nil, err 182 + } 183 + 184 + // Parse gateUri to extract rkey 185 + rkey, err := extractRKey(input.GateUri) 186 + if err != nil { 187 + log.Error(ctx, "failed to extract rkey from gateUri", "uri", input.GateUri, "err", err) 188 + return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid gateUri format") 189 + } 190 + 191 + // Delete gate record from streamer's repo 192 + deleteInput := comatproto.RepoDeleteRecord_Input{ 193 + Collection: constants.PLACE_STREAM_CHAT_GATE, 194 + Rkey: rkey, 195 + Repo: input.Streamer, 196 + } 197 + deleteOutput := comatproto.RepoDeleteRecord_Output{} 198 + 199 + err = modCtx.StreamerClient.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", map[string]any{}, deleteInput, &deleteOutput) 200 + if err != nil { 201 + log.Error(ctx, "failed to delete gate record", "err", err) 202 + if auditErr := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "deleteGate", input.GateUri, "", "", false, err.Error()); auditErr != nil { 203 + log.Error(ctx, "failed to create audit log", "error", auditErr) 204 + } 205 + return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete gate: %v", err)) 206 + } 207 + 208 + // Log successful audit entry 209 + if err := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "deleteGate", input.GateUri, "", "", true, ""); err != nil { 210 + log.Error(ctx, "failed to create audit log", "error", err) 211 + } 212 + 213 + return &streamplace.ModerationDeleteGate_Output{}, nil 214 + } 215 + 216 + // handlePlaceStreamModerationUpdateLivestream updates livestream metadata on behalf of a streamer 217 + func (s *Server) handlePlaceStreamModerationUpdateLivestream(ctx context.Context, input *streamplace.ModerationUpdateLivestream_Input) (*streamplace.ModerationUpdateLivestream_Output, error) { 218 + // Validate input 219 + if err := validateDID(input.Streamer); err != nil { 220 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid streamer DID: %v", err)) 221 + } 222 + if err := validateATURI(input.LivestreamUri); err != nil { 223 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid livestream URI: %v", err)) 224 + } 225 + 226 + // Get delegated moderation context (validates OAuth, permission, and returns client) 227 + modCtx, err := s.GetDelegatedModerationContext(ctx, input.Streamer, "updateLivestream") 228 + if err != nil { 229 + return nil, err 230 + } 231 + 232 + // Parse livestreamUri to extract rkey 233 + rkey, err := extractRKey(input.LivestreamUri) 234 + if err != nil { 235 + log.Error(ctx, "failed to extract rkey from livestreamUri", "uri", input.LivestreamUri, "err", err) 236 + return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid livestreamUri format") 237 + } 238 + 239 + // Get existing livestream record 240 + getInput := map[string]any{ 241 + "repo": input.Streamer, 242 + "collection": constants.PLACE_STREAM_LIVESTREAM, 243 + "rkey": rkey, 244 + } 245 + getOutput := comatproto.RepoGetRecord_Output{} 246 + err = modCtx.StreamerClient.Do(ctx, xrpc.Query, "application/json", "com.atproto.repo.getRecord", getInput, nil, &getOutput) 247 + if err != nil { 248 + log.Error(ctx, "failed to get livestream record", "err", err) 249 + if auditErr := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "updateLivestream", input.LivestreamUri, "", "", false, fmt.Sprintf("failed to get record: %v", err)); auditErr != nil { 250 + log.Error(ctx, "failed to create audit log", "error", auditErr) 251 + } 252 + return nil, echo.NewHTTPError(http.StatusNotFound, "livestream record not found") 253 + } 254 + 255 + // Decode existing record 256 + if getOutput.Value == nil || getOutput.Value.Val == nil { 257 + log.Error(ctx, "livestream record value is nil") 258 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to decode livestream record") 259 + } 260 + 261 + // Convert the decoded value to our struct 262 + livestream := &streamplace.Livestream{} 263 + recordBytes, err := json.Marshal(getOutput.Value.Val) 264 + if err != nil { 265 + log.Error(ctx, "failed to marshal livestream record", "err", err) 266 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to decode livestream record") 267 + } 268 + err = json.Unmarshal(recordBytes, livestream) 269 + if err != nil { 270 + log.Error(ctx, "failed to unmarshal livestream record", "err", err) 271 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to decode livestream record") 272 + } 273 + 274 + // Create new record (don't edit existing - old records serve as "chapter markers") 275 + // Copy fields from existing record and update title 276 + if input.Title != nil { 277 + livestream.Title = *input.Title 278 + } 279 + 280 + // Ensure notificationSettings.pushNotification is false for mods 281 + if livestream.NotificationSettings == nil { 282 + livestream.NotificationSettings = &streamplace.Livestream_NotificationSettings{} 283 + } 284 + pushNotificationFalse := false 285 + livestream.NotificationSettings.PushNotification = &pushNotificationFalse 286 + 287 + // Update createdAt to current time for new record 288 + livestream.CreatedAt = time.Now().UTC().Format(time.RFC3339) 289 + 290 + // Create new record instead of updating existing 291 + createInput := comatproto.RepoCreateRecord_Input{ 292 + Collection: constants.PLACE_STREAM_LIVESTREAM, 293 + Record: &lexutil.LexiconTypeDecoder{Val: livestream}, 294 + Repo: input.Streamer, 295 + } 296 + createOutput := comatproto.RepoCreateRecord_Output{} 297 + 298 + err = modCtx.StreamerClient.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", map[string]any{}, createInput, &createOutput) 299 + if err != nil { 300 + log.Error(ctx, "failed to create livestream record", "err", err) 301 + if auditErr := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "updateLivestream", input.LivestreamUri, "", "", false, err.Error()); auditErr != nil { 302 + log.Error(ctx, "failed to create audit log", "error", auditErr) 303 + } 304 + return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create livestream: %v", err)) 305 + } 306 + 307 + // Log successful audit entry 308 + if err := s.logAudit(ctx, input.Streamer, modCtx.ModeratorDID, "updateLivestream", input.LivestreamUri, "", createOutput.Uri, true, ""); err != nil { 309 + log.Error(ctx, "failed to create audit log", "error", err) 310 + } 311 + 312 + return &streamplace.ModerationUpdateLivestream_Output{ 313 + Uri: createOutput.Uri, 314 + Cid: createOutput.Cid, 315 + }, nil 316 + } 317 + 318 + // Helper functions 319 + 320 + // extractRKey extracts the rkey from an AT-URI (at://did:plc:xxx/collection/rkey) 321 + func extractRKey(uri string) (string, error) { 322 + aturi, err := syntax.ParseATURI(uri) 323 + if err != nil { 324 + return "", fmt.Errorf("invalid AT-URI: %w", err) 325 + } 326 + return aturi.RecordKey().String(), nil 327 + } 328 + 329 + // validateDID checks if string is a valid AT Protocol DID format 330 + func validateDID(did string) error { 331 + _, err := syntax.ParseDID(did) 332 + if err != nil { 333 + return fmt.Errorf("invalid DID format: %w", err) 334 + } 335 + return nil 336 + } 337 + 338 + // validateATURI checks if string is a valid AT-URI format 339 + func validateATURI(uri string) error { 340 + _, err := syntax.ParseATURI(uri) 341 + if err != nil { 342 + return fmt.Errorf("invalid AT-URI format: %w", err) 343 + } 344 + return nil 345 + } 346 + 347 + // logAudit logs a moderation action to the audit log 348 + func (s *Server) logAudit(ctx context.Context, streamerDID, moderatorDID, action, targetURI, targetDID, resultURI string, success bool, errorMsg string) error { 349 + auditLog := &statedb.ModerationAuditLog{ 350 + StreamerDID: streamerDID, 351 + ModeratorDID: moderatorDID, 352 + Action: action, 353 + TargetURI: targetURI, 354 + TargetDID: targetDID, 355 + ResultURI: resultURI, 356 + Success: success, 357 + ErrorMsg: errorMsg, 358 + CreatedAt: time.Now(), 359 + } 360 + 361 + return s.statefulDB.CreateAuditLog(ctx, auditLog) 362 + }
+2
pkg/spxrpc/spxrpc.go
··· 31 31 ATSync *atproto.ATProtoSynchronizer 32 32 statefulDB *statedb.StatefulDB 33 33 bus *bus.Bus 34 + op *oatproxy.OATProxy 34 35 } 35 36 36 37 func NewServer(ctx context.Context, cli *config.CLI, model model.Model, statefulDB *statedb.StatefulDB, op *oatproxy.OATProxy, mdlw middleware.Middleware, atsync *atproto.ATProtoSynchronizer, bus *bus.Bus) (*Server, error) { ··· 43 44 ATSync: atsync, 44 45 statefulDB: statefulDB, 45 46 bus: bus, 47 + op: op, 46 48 } 47 49 e.Use(s.ErrorHandlingMiddleware()) 48 50 e.Use(s.ContextPreservingMiddleware())
+95
pkg/spxrpc/stubs.go
··· 269 269 e.GET("/xrpc/place.stream.live.getRecommendations", s.HandlePlaceStreamLiveGetRecommendations) 270 270 e.GET("/xrpc/place.stream.live.getSegments", s.HandlePlaceStreamLiveGetSegments) 271 271 e.GET("/xrpc/place.stream.live.searchActorsTypeahead", s.HandlePlaceStreamLiveSearchActorsTypeahead) 272 + e.POST("/xrpc/place.stream.moderation.createBlock", s.HandlePlaceStreamModerationCreateBlock) 273 + e.POST("/xrpc/place.stream.moderation.createGate", s.HandlePlaceStreamModerationCreateGate) 274 + e.POST("/xrpc/place.stream.moderation.deleteBlock", s.HandlePlaceStreamModerationDeleteBlock) 275 + e.POST("/xrpc/place.stream.moderation.deleteGate", s.HandlePlaceStreamModerationDeleteGate) 276 + e.POST("/xrpc/place.stream.moderation.updateLivestream", s.HandlePlaceStreamModerationUpdateLivestream) 272 277 e.POST("/xrpc/place.stream.server.createWebhook", s.HandlePlaceStreamServerCreateWebhook) 273 278 e.POST("/xrpc/place.stream.server.deleteWebhook", s.HandlePlaceStreamServerDeleteWebhook) 274 279 e.GET("/xrpc/place.stream.server.getServerTime", s.HandlePlaceStreamServerGetServerTime) ··· 404 409 var handleErr error 405 410 // func (s *Server) handlePlaceStreamLiveSearchActorsTypeahead(ctx context.Context,limit int,q string) (*placestream.LiveSearchActorsTypeahead_Output, error) 406 411 out, handleErr = s.handlePlaceStreamLiveSearchActorsTypeahead(ctx, limit, q) 412 + if handleErr != nil { 413 + return handleErr 414 + } 415 + return c.JSON(200, out) 416 + } 417 + 418 + func (s *Server) HandlePlaceStreamModerationCreateBlock(c echo.Context) error { 419 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationCreateBlock") 420 + defer span.End() 421 + 422 + var body placestream.ModerationCreateBlock_Input 423 + if err := c.Bind(&body); err != nil { 424 + return err 425 + } 426 + var out *placestream.ModerationCreateBlock_Output 427 + var handleErr error 428 + // func (s *Server) handlePlaceStreamModerationCreateBlock(ctx context.Context,body *placestream.ModerationCreateBlock_Input) (*placestream.ModerationCreateBlock_Output, error) 429 + out, handleErr = s.handlePlaceStreamModerationCreateBlock(ctx, &body) 430 + if handleErr != nil { 431 + return handleErr 432 + } 433 + return c.JSON(200, out) 434 + } 435 + 436 + func (s *Server) HandlePlaceStreamModerationCreateGate(c echo.Context) error { 437 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationCreateGate") 438 + defer span.End() 439 + 440 + var body placestream.ModerationCreateGate_Input 441 + if err := c.Bind(&body); err != nil { 442 + return err 443 + } 444 + var out *placestream.ModerationCreateGate_Output 445 + var handleErr error 446 + // func (s *Server) handlePlaceStreamModerationCreateGate(ctx context.Context,body *placestream.ModerationCreateGate_Input) (*placestream.ModerationCreateGate_Output, error) 447 + out, handleErr = s.handlePlaceStreamModerationCreateGate(ctx, &body) 448 + if handleErr != nil { 449 + return handleErr 450 + } 451 + return c.JSON(200, out) 452 + } 453 + 454 + func (s *Server) HandlePlaceStreamModerationDeleteBlock(c echo.Context) error { 455 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationDeleteBlock") 456 + defer span.End() 457 + 458 + var body placestream.ModerationDeleteBlock_Input 459 + if err := c.Bind(&body); err != nil { 460 + return err 461 + } 462 + var out *placestream.ModerationDeleteBlock_Output 463 + var handleErr error 464 + // func (s *Server) handlePlaceStreamModerationDeleteBlock(ctx context.Context,body *placestream.ModerationDeleteBlock_Input) (*placestream.ModerationDeleteBlock_Output, error) 465 + out, handleErr = s.handlePlaceStreamModerationDeleteBlock(ctx, &body) 466 + if handleErr != nil { 467 + return handleErr 468 + } 469 + return c.JSON(200, out) 470 + } 471 + 472 + func (s *Server) HandlePlaceStreamModerationDeleteGate(c echo.Context) error { 473 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationDeleteGate") 474 + defer span.End() 475 + 476 + var body placestream.ModerationDeleteGate_Input 477 + if err := c.Bind(&body); err != nil { 478 + return err 479 + } 480 + var out *placestream.ModerationDeleteGate_Output 481 + var handleErr error 482 + // func (s *Server) handlePlaceStreamModerationDeleteGate(ctx context.Context,body *placestream.ModerationDeleteGate_Input) (*placestream.ModerationDeleteGate_Output, error) 483 + out, handleErr = s.handlePlaceStreamModerationDeleteGate(ctx, &body) 484 + if handleErr != nil { 485 + return handleErr 486 + } 487 + return c.JSON(200, out) 488 + } 489 + 490 + func (s *Server) HandlePlaceStreamModerationUpdateLivestream(c echo.Context) error { 491 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamModerationUpdateLivestream") 492 + defer span.End() 493 + 494 + var body placestream.ModerationUpdateLivestream_Input 495 + if err := c.Bind(&body); err != nil { 496 + return err 497 + } 498 + var out *placestream.ModerationUpdateLivestream_Output 499 + var handleErr error 500 + // func (s *Server) handlePlaceStreamModerationUpdateLivestream(ctx context.Context,body *placestream.ModerationUpdateLivestream_Input) (*placestream.ModerationUpdateLivestream_Output, error) 501 + out, handleErr = s.handlePlaceStreamModerationUpdateLivestream(ctx, &body) 407 502 if handleErr != nil { 408 503 return handleErr 409 504 }
+61
pkg/statedb/moderation_audit_log.go
··· 1 + package statedb 2 + 3 + import ( 4 + "context" 5 + "time" 6 + ) 7 + 8 + type ModerationAuditLog struct { 9 + ID uint `gorm:"primaryKey;autoIncrement"` 10 + StreamerDID string `gorm:"column:streamer_did;index:idx_streamer_time,priority:1"` 11 + ModeratorDID string `gorm:"column:moderator_did;index:idx_audit_moderator"` 12 + Action string `gorm:"column:action"` // "createBlock", "deleteBlock", "createGate", "deleteGate", "updateLivestream" 13 + TargetURI string `gorm:"column:target_uri"` // URI of affected resource 14 + TargetDID string `gorm:"column:target_did"` // DID of affected user (for blocks) 15 + ResultURI string `gorm:"column:result_uri"` // URI of created/deleted record 16 + Success bool `gorm:"column:success"` 17 + ErrorMsg string `gorm:"column:error_msg"` 18 + CreatedAt time.Time `gorm:"column:created_at;index:idx_streamer_time,priority:2"` 19 + } 20 + 21 + func (ModerationAuditLog) TableName() string { 22 + return "moderation_audit_logs" 23 + } 24 + 25 + func (state *StatefulDB) CreateAuditLog(ctx context.Context, log *ModerationAuditLog) error { 26 + return state.DB.WithContext(ctx).Create(log).Error 27 + } 28 + 29 + func (state *StatefulDB) GetAuditLogs(ctx context.Context, streamerDID string, limit int, before *time.Time) ([]*ModerationAuditLog, error) { 30 + var logs []*ModerationAuditLog 31 + query := state.DB.WithContext(ctx).Where("streamer_did = ?", streamerDID). 32 + Order("created_at DESC"). 33 + Limit(limit) 34 + 35 + if before != nil { 36 + query = query.Where("created_at < ?", *before) 37 + } 38 + 39 + err := query.Find(&logs).Error 40 + if err != nil { 41 + return nil, err 42 + } 43 + return logs, nil 44 + } 45 + 46 + func (state *StatefulDB) GetModeratorAuditLogs(ctx context.Context, moderatorDID string, limit int, before *time.Time) ([]*ModerationAuditLog, error) { 47 + var logs []*ModerationAuditLog 48 + query := state.DB.WithContext(ctx).Where("moderator_did = ?", moderatorDID). 49 + Order("created_at DESC"). 50 + Limit(limit) 51 + 52 + if before != nil { 53 + query = query.Where("created_at < ?", *before) 54 + } 55 + 56 + err := query.Find(&logs).Error 57 + if err != nil { 58 + return nil, err 59 + } 60 + return logs, nil 61 + }
+1
pkg/statedb/statedb.go
··· 49 49 AppTask{}, 50 50 Repo{}, 51 51 Webhook{}, 52 + ModerationAuditLog{}, 52 53 } 53 54 54 55 var NoPostgresDatabaseCode = "3D000"
+295
pkg/streamplace/cbor_gen.go
··· 4975 4975 4976 4976 return nil 4977 4977 } 4978 + func (t *ModerationPermission) MarshalCBOR(w io.Writer) error { 4979 + if t == nil { 4980 + _, err := w.Write(cbg.CborNull) 4981 + return err 4982 + } 4983 + 4984 + cw := cbg.NewCborWriter(w) 4985 + fieldCount := 5 4986 + 4987 + if t.ExpirationTime == nil { 4988 + fieldCount-- 4989 + } 4990 + 4991 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 4992 + return err 4993 + } 4994 + 4995 + // t.LexiconTypeID (string) (string) 4996 + if len("$type") > 1000000 { 4997 + return xerrors.Errorf("Value in field \"$type\" was too long") 4998 + } 4999 + 5000 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 5001 + return err 5002 + } 5003 + if _, err := cw.WriteString(string("$type")); err != nil { 5004 + return err 5005 + } 5006 + 5007 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("place.stream.moderation.permission"))); err != nil { 5008 + return err 5009 + } 5010 + if _, err := cw.WriteString(string("place.stream.moderation.permission")); err != nil { 5011 + return err 5012 + } 5013 + 5014 + // t.CreatedAt (string) (string) 5015 + if len("createdAt") > 1000000 { 5016 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 5017 + } 5018 + 5019 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 5020 + return err 5021 + } 5022 + if _, err := cw.WriteString(string("createdAt")); err != nil { 5023 + return err 5024 + } 5025 + 5026 + if len(t.CreatedAt) > 1000000 { 5027 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 5028 + } 5029 + 5030 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 5031 + return err 5032 + } 5033 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 5034 + return err 5035 + } 5036 + 5037 + // t.Moderator (string) (string) 5038 + if len("moderator") > 1000000 { 5039 + return xerrors.Errorf("Value in field \"moderator\" was too long") 5040 + } 5041 + 5042 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("moderator"))); err != nil { 5043 + return err 5044 + } 5045 + if _, err := cw.WriteString(string("moderator")); err != nil { 5046 + return err 5047 + } 5048 + 5049 + if len(t.Moderator) > 1000000 { 5050 + return xerrors.Errorf("Value in field t.Moderator was too long") 5051 + } 5052 + 5053 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Moderator))); err != nil { 5054 + return err 5055 + } 5056 + if _, err := cw.WriteString(string(t.Moderator)); err != nil { 5057 + return err 5058 + } 5059 + 5060 + // t.Permissions ([]string) (slice) 5061 + if len("permissions") > 1000000 { 5062 + return xerrors.Errorf("Value in field \"permissions\" was too long") 5063 + } 5064 + 5065 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("permissions"))); err != nil { 5066 + return err 5067 + } 5068 + if _, err := cw.WriteString(string("permissions")); err != nil { 5069 + return err 5070 + } 5071 + 5072 + if len(t.Permissions) > 8192 { 5073 + return xerrors.Errorf("Slice value in field t.Permissions was too long") 5074 + } 5075 + 5076 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Permissions))); err != nil { 5077 + return err 5078 + } 5079 + for _, v := range t.Permissions { 5080 + if len(v) > 1000000 { 5081 + return xerrors.Errorf("Value in field v was too long") 5082 + } 5083 + 5084 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 5085 + return err 5086 + } 5087 + if _, err := cw.WriteString(string(v)); err != nil { 5088 + return err 5089 + } 5090 + 5091 + } 5092 + 5093 + // t.ExpirationTime (string) (string) 5094 + if t.ExpirationTime != nil { 5095 + 5096 + if len("expirationTime") > 1000000 { 5097 + return xerrors.Errorf("Value in field \"expirationTime\" was too long") 5098 + } 5099 + 5100 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("expirationTime"))); err != nil { 5101 + return err 5102 + } 5103 + if _, err := cw.WriteString(string("expirationTime")); err != nil { 5104 + return err 5105 + } 5106 + 5107 + if t.ExpirationTime == nil { 5108 + if _, err := cw.Write(cbg.CborNull); err != nil { 5109 + return err 5110 + } 5111 + } else { 5112 + if len(*t.ExpirationTime) > 1000000 { 5113 + return xerrors.Errorf("Value in field t.ExpirationTime was too long") 5114 + } 5115 + 5116 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ExpirationTime))); err != nil { 5117 + return err 5118 + } 5119 + if _, err := cw.WriteString(string(*t.ExpirationTime)); err != nil { 5120 + return err 5121 + } 5122 + } 5123 + } 5124 + return nil 5125 + } 5126 + 5127 + func (t *ModerationPermission) UnmarshalCBOR(r io.Reader) (err error) { 5128 + *t = ModerationPermission{} 5129 + 5130 + cr := cbg.NewCborReader(r) 5131 + 5132 + maj, extra, err := cr.ReadHeader() 5133 + if err != nil { 5134 + return err 5135 + } 5136 + defer func() { 5137 + if err == io.EOF { 5138 + err = io.ErrUnexpectedEOF 5139 + } 5140 + }() 5141 + 5142 + if maj != cbg.MajMap { 5143 + return fmt.Errorf("cbor input should be of type map") 5144 + } 5145 + 5146 + if extra > cbg.MaxLength { 5147 + return fmt.Errorf("ModerationPermission: map struct too large (%d)", extra) 5148 + } 5149 + 5150 + n := extra 5151 + 5152 + nameBuf := make([]byte, 14) 5153 + for i := uint64(0); i < n; i++ { 5154 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 5155 + if err != nil { 5156 + return err 5157 + } 5158 + 5159 + if !ok { 5160 + // Field doesn't exist on this type, so ignore it 5161 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 5162 + return err 5163 + } 5164 + continue 5165 + } 5166 + 5167 + switch string(nameBuf[:nameLen]) { 5168 + // t.LexiconTypeID (string) (string) 5169 + case "$type": 5170 + 5171 + { 5172 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5173 + if err != nil { 5174 + return err 5175 + } 5176 + 5177 + t.LexiconTypeID = string(sval) 5178 + } 5179 + // t.CreatedAt (string) (string) 5180 + case "createdAt": 5181 + 5182 + { 5183 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5184 + if err != nil { 5185 + return err 5186 + } 5187 + 5188 + t.CreatedAt = string(sval) 5189 + } 5190 + // t.Moderator (string) (string) 5191 + case "moderator": 5192 + 5193 + { 5194 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5195 + if err != nil { 5196 + return err 5197 + } 5198 + 5199 + t.Moderator = string(sval) 5200 + } 5201 + // t.Permissions ([]string) (slice) 5202 + case "permissions": 5203 + 5204 + maj, extra, err = cr.ReadHeader() 5205 + if err != nil { 5206 + return err 5207 + } 5208 + 5209 + if extra > 8192 { 5210 + return fmt.Errorf("t.Permissions: array too large (%d)", extra) 5211 + } 5212 + 5213 + if maj != cbg.MajArray { 5214 + return fmt.Errorf("expected cbor array") 5215 + } 5216 + 5217 + if extra > 0 { 5218 + t.Permissions = make([]string, extra) 5219 + } 5220 + 5221 + for i := 0; i < int(extra); i++ { 5222 + { 5223 + var maj byte 5224 + var extra uint64 5225 + var err error 5226 + _ = maj 5227 + _ = extra 5228 + _ = err 5229 + 5230 + { 5231 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5232 + if err != nil { 5233 + return err 5234 + } 5235 + 5236 + t.Permissions[i] = string(sval) 5237 + } 5238 + 5239 + } 5240 + } 5241 + // t.ExpirationTime (string) (string) 5242 + case "expirationTime": 5243 + 5244 + { 5245 + b, err := cr.ReadByte() 5246 + if err != nil { 5247 + return err 5248 + } 5249 + if b != cbg.CborNull[0] { 5250 + if err := cr.UnreadByte(); err != nil { 5251 + return err 5252 + } 5253 + 5254 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5255 + if err != nil { 5256 + return err 5257 + } 5258 + 5259 + t.ExpirationTime = (*string)(&sval) 5260 + } 5261 + } 5262 + 5263 + default: 5264 + // Field doesn't exist on this type, so ignore it 5265 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 5266 + return err 5267 + } 5268 + } 5269 + } 5270 + 5271 + return nil 5272 + } 4978 5273 func (t *LiveRecommendations) MarshalCBOR(w io.Writer) error { 4979 5274 if t == nil { 4980 5275 _, err := w.Write(cbg.CborNull)
+39
pkg/streamplace/moderationcreateBlock.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.moderation.createBlock 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // ModerationCreateBlock_Input is the input argument to a place.stream.moderation.createBlock call. 14 + type ModerationCreateBlock_Input struct { 15 + // reason: Optional reason for the block. 16 + Reason *string `json:"reason,omitempty" cborgen:"reason,omitempty"` 17 + // streamer: The DID of the streamer whose chat this block applies to. 18 + Streamer string `json:"streamer" cborgen:"streamer"` 19 + // subject: The DID of the user being blocked from chat. 20 + Subject string `json:"subject" cborgen:"subject"` 21 + } 22 + 23 + // ModerationCreateBlock_Output is the output of a place.stream.moderation.createBlock call. 24 + type ModerationCreateBlock_Output struct { 25 + // cid: The CID of the created block record. 26 + Cid string `json:"cid" cborgen:"cid"` 27 + // uri: The AT-URI of the created block record. 28 + Uri string `json:"uri" cborgen:"uri"` 29 + } 30 + 31 + // ModerationCreateBlock calls the XRPC method "place.stream.moderation.createBlock". 32 + func ModerationCreateBlock(ctx context.Context, c lexutil.LexClient, input *ModerationCreateBlock_Input) (*ModerationCreateBlock_Output, error) { 33 + var out ModerationCreateBlock_Output 34 + if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.moderation.createBlock", nil, input, &out); err != nil { 35 + return nil, err 36 + } 37 + 38 + return &out, nil 39 + }
+37
pkg/streamplace/moderationcreateGate.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.moderation.createGate 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // ModerationCreateGate_Input is the input argument to a place.stream.moderation.createGate call. 14 + type ModerationCreateGate_Input struct { 15 + // messageUri: The AT-URI of the chat message to hide. 16 + MessageUri string `json:"messageUri" cborgen:"messageUri"` 17 + // streamer: The DID of the streamer. 18 + Streamer string `json:"streamer" cborgen:"streamer"` 19 + } 20 + 21 + // ModerationCreateGate_Output is the output of a place.stream.moderation.createGate call. 22 + type ModerationCreateGate_Output struct { 23 + // cid: The CID of the created gate record. 24 + Cid string `json:"cid" cborgen:"cid"` 25 + // uri: The AT-URI of the created gate record. 26 + Uri string `json:"uri" cborgen:"uri"` 27 + } 28 + 29 + // ModerationCreateGate calls the XRPC method "place.stream.moderation.createGate". 30 + func ModerationCreateGate(ctx context.Context, c lexutil.LexClient, input *ModerationCreateGate_Input) (*ModerationCreateGate_Output, error) { 31 + var out ModerationCreateGate_Output 32 + if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.moderation.createGate", nil, input, &out); err != nil { 33 + return nil, err 34 + } 35 + 36 + return &out, nil 37 + }
+22
pkg/streamplace/moderationdefs.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.moderation.defs 4 + 5 + package streamplace 6 + 7 + import ( 8 + appbsky "github.com/bluesky-social/indigo/api/bsky" 9 + lexutil "github.com/bluesky-social/indigo/lex/util" 10 + ) 11 + 12 + // ModerationDefs_PermissionView is a "permissionView" in the place.stream.moderation.defs schema. 13 + type ModerationDefs_PermissionView struct { 14 + // author: The streamer who granted these permissions 15 + Author *appbsky.ActorDefs_ProfileViewBasic `json:"author" cborgen:"author"` 16 + // cid: Content identifier of the permission record 17 + Cid string `json:"cid" cborgen:"cid"` 18 + // record: The permission record itself 19 + Record *lexutil.LexiconTypeDecoder `json:"record" cborgen:"record"` 20 + // uri: AT-URI of the permission record 21 + Uri string `json:"uri" cborgen:"uri"` 22 + }
+33
pkg/streamplace/moderationdeleteBlock.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.moderation.deleteBlock 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // ModerationDeleteBlock_Input is the input argument to a place.stream.moderation.deleteBlock call. 14 + type ModerationDeleteBlock_Input struct { 15 + // blockUri: The AT-URI of the block record to delete. 16 + BlockUri string `json:"blockUri" cborgen:"blockUri"` 17 + // streamer: The DID of the streamer. 18 + Streamer string `json:"streamer" cborgen:"streamer"` 19 + } 20 + 21 + // ModerationDeleteBlock_Output is the output of a place.stream.moderation.deleteBlock call. 22 + type ModerationDeleteBlock_Output struct { 23 + } 24 + 25 + // ModerationDeleteBlock calls the XRPC method "place.stream.moderation.deleteBlock". 26 + func ModerationDeleteBlock(ctx context.Context, c lexutil.LexClient, input *ModerationDeleteBlock_Input) (*ModerationDeleteBlock_Output, error) { 27 + var out ModerationDeleteBlock_Output 28 + if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.moderation.deleteBlock", nil, input, &out); err != nil { 29 + return nil, err 30 + } 31 + 32 + return &out, nil 33 + }
+33
pkg/streamplace/moderationdeleteGate.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.moderation.deleteGate 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // ModerationDeleteGate_Input is the input argument to a place.stream.moderation.deleteGate call. 14 + type ModerationDeleteGate_Input struct { 15 + // gateUri: The AT-URI of the gate record to delete. 16 + GateUri string `json:"gateUri" cborgen:"gateUri"` 17 + // streamer: The DID of the streamer. 18 + Streamer string `json:"streamer" cborgen:"streamer"` 19 + } 20 + 21 + // ModerationDeleteGate_Output is the output of a place.stream.moderation.deleteGate call. 22 + type ModerationDeleteGate_Output struct { 23 + } 24 + 25 + // ModerationDeleteGate calls the XRPC method "place.stream.moderation.deleteGate". 26 + func ModerationDeleteGate(ctx context.Context, c lexutil.LexClient, input *ModerationDeleteGate_Input) (*ModerationDeleteGate_Output, error) { 27 + var out ModerationDeleteGate_Output 28 + if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.moderation.deleteGate", nil, input, &out); err != nil { 29 + return nil, err 30 + } 31 + 32 + return &out, nil 33 + }
+25
pkg/streamplace/moderationpermission.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.moderation.permission 4 + 5 + package streamplace 6 + 7 + import ( 8 + lexutil "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + func init() { 12 + lexutil.RegisterType("place.stream.moderation.permission", &ModerationPermission{}) 13 + } 14 + 15 + type ModerationPermission struct { 16 + LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.moderation.permission"` 17 + // createdAt: Client-declared timestamp when this moderator was added. 18 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 19 + // expirationTime: Optional expiration time for this delegation. If set, the delegation is invalid after this time. 20 + ExpirationTime *string `json:"expirationTime,omitempty" cborgen:"expirationTime,omitempty"` 21 + // moderator: The DID of the user granted moderator permissions. 22 + Moderator string `json:"moderator" cborgen:"moderator"` 23 + // permissions: Array of permissions granted to this moderator. 'ban' covers blocks/bans (with optional expiration), 'hide' covers message gates, 'livestream.manage' allows updating livestream metadata. 24 + Permissions []string `json:"permissions" cborgen:"permissions"` 25 + }
+39
pkg/streamplace/moderationupdateLivestream.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + // Lexicon schema: place.stream.moderation.updateLivestream 4 + 5 + package streamplace 6 + 7 + import ( 8 + "context" 9 + 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // ModerationUpdateLivestream_Input is the input argument to a place.stream.moderation.updateLivestream call. 14 + type ModerationUpdateLivestream_Input struct { 15 + // livestreamUri: The AT-URI of the livestream record to update. 16 + LivestreamUri string `json:"livestreamUri" cborgen:"livestreamUri"` 17 + // streamer: The DID of the streamer. 18 + Streamer string `json:"streamer" cborgen:"streamer"` 19 + // title: New title for the livestream. 20 + Title *string `json:"title,omitempty" cborgen:"title,omitempty"` 21 + } 22 + 23 + // ModerationUpdateLivestream_Output is the output of a place.stream.moderation.updateLivestream call. 24 + type ModerationUpdateLivestream_Output struct { 25 + // cid: The CID of the updated livestream record. 26 + Cid string `json:"cid" cborgen:"cid"` 27 + // uri: The AT-URI of the updated livestream record. 28 + Uri string `json:"uri" cborgen:"uri"` 29 + } 30 + 31 + // ModerationUpdateLivestream calls the XRPC method "place.stream.moderation.updateLivestream". 32 + func ModerationUpdateLivestream(ctx context.Context, c lexutil.LexClient, input *ModerationUpdateLivestream_Input) (*ModerationUpdateLivestream_Output, error) { 33 + var out ModerationUpdateLivestream_Output 34 + if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.moderation.updateLivestream", nil, input, &out); err != nil { 35 + return nil, err 36 + } 37 + 38 + return &out, nil 39 + }