Evented architecture for moderation system ()

* 🚧 WIP with proposed lexicons for event based mod architecture

* 🚧 Remove unnecessary moderation action lexicon

* 🚧 Working on event based actions

*  Add escalated subject status

* 🐛 Alright, fixed the error in lexicon

* 🚧 Working through reversal

*  Cleanup build errors

*  Add subject status endpoint

*  Add handler

*  get reports from mod actions table

* :rightwards_twisted_arrows: Merge with upstream

* 🚧 Builds but test network doesnt start

*  Tests passing on event based status change

*  Rename index

* ♻️ Rename takeModerationAction->emitModerationEvent

*  Implement label reversal

*  Auto-revert test working

* ♻️  Refactored to event types and tests are passing

*  Add takedown event sequence validation

*  Adds support for blobCid status

* 🧹 Cleanup unnecessary method:

*  Hydrate handles with status and events

*  Re-implement auto reversal

*  Add takendown and mute filters

*  Allow filtering events by type

*  Allow filtering events by creator did

*  Add subjectStatus to record and repoview

*  Add persistent note feature

*  Log send email event

* 🐛 Fix logging send email event

*  Better type

*  Adjust migration to create separate moderation_event table

* 🧹 Cleanup types

*  Adjust tests with mod event emitter

*  Fix more tests around takedowns

*  Get test suite to pass

*  Get test suite to pass for pds

*  Get test suite to pass for pds

*  Update snapshot for feedgen

*  Why are more snapshots updating?

* ♻️ Rename getModerationEvents -> queryModerationEvents

* ♻️ Rename getModerationStatuses -> queryModerationStatuses

* ♻️ Rename persistNote->sticky

* 🐛 Rename subject

* ♻️ Cleanup expiresAt for scheduled actions

*  Add more tests, allow fetching mod history for all content by a user

*  Fix repo and record tests

*  Migrate reports and actions to events

* 🐛 Fix escalated status overwrite

*  Implement direct sql query to create events from actions and reports

* 🚧 Adding keyset pagination for subject statuses

*  Add migration for lastReportedAt

*  Migrate blob cids

*  Fix pagination on mod subject list endpoint

* 🐛 Fix blob actions

*  All tests passing on bsky package

*  Bring back snapshots

*  Skipping timeline test temporarily

*  Skipping some more tests to isolate failing ones

*  Bring back list-feed test

*  Bring back timeline test

*  Fix label action in seeding

*  Enable timeline proxied test

*  Enable search actor proxied test

*  Enable feedgen tests

*  Fix test for admin/get-record

*  Move note to comment for subject status

*  Accept comments in mute event

*  Remap flag event to ack event

* 🐛 Add legacyRef in report union selection

* @atproto/api 0.6.24-next.0

* @atproto/api 0.6.24-next.1

*  Adjust migration export and add index for blobCids column

*  Maintin action ids when migrating

*  Paginate events using createdAt timestamp

*  Update snapshot for pds test with events cursor update

*  Use only events for snapshot testing

*  Use only events for snapshot in the remaining test

* relative paths to lexicons for build

* fix bsky periodic event reversal in service entrypoint

*  Allow comments in takedown and label

*  Only import reports on consecutive run of the migration script

*  Adjust moderation property of blob entries

* determine latest reports to migrate

*  Process new reports for subject status

*  Process unresolved reports on first migration run

* fix transaction error, process just unresolved reports, make reported-at updates safe for reruns

* tidy

---------

Co-authored-by: Devin Ivy <devinivy@gmail.com>
This commit is contained in:
Foysal Ahamed 2023-11-30 17:53:56 +01:00 committed by GitHub
parent 7edad62c12
commit 1f9040a44d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
119 changed files with 6771 additions and 7403 deletions
lexicons/com/atproto/admin
packages
api
bsky
src
tests
dev-env/src
pds/src/api/com/atproto/admin

@ -10,57 +10,67 @@
"ref": { "type": "string" }
}
},
"actionView": {
"modEventView": {
"type": "object",
"required": [
"id",
"action",
"event",
"subject",
"subjectBlobCids",
"reason",
"createdBy",
"createdAt",
"resolvedReportIds"
"createdAt"
],
"properties": {
"id": { "type": "integer" },
"action": { "type": "ref", "ref": "#actionType" },
"durationInHours": {
"type": "integer",
"description": "Indicates how long this action is meant to be in effect before automatically expiring."
"event": {
"type": "union",
"refs": [
"#modEventTakedown",
"#modEventReverseTakedown",
"#modEventComment",
"#modEventReport",
"#modEventLabel",
"#modEventAcknowledge",
"#modEventEscalate",
"#modEventMute",
"#modEventEmail"
]
},
"subject": {
"type": "union",
"refs": ["#repoRef", "com.atproto.repo.strongRef"]
},
"subjectBlobCids": { "type": "array", "items": { "type": "string" } },
"createLabelVals": { "type": "array", "items": { "type": "string" } },
"negateLabelVals": { "type": "array", "items": { "type": "string" } },
"reason": { "type": "string" },
"createdBy": { "type": "string", "format": "did" },
"createdAt": { "type": "string", "format": "datetime" },
"reversal": { "type": "ref", "ref": "#actionReversal" },
"resolvedReportIds": { "type": "array", "items": { "type": "integer" } }
"creatorHandle": { "type": "string" },
"subjectHandle": { "type": "string" }
}
},
"actionViewDetail": {
"modEventViewDetail": {
"type": "object",
"required": [
"id",
"action",
"event",
"subject",
"subjectBlobs",
"reason",
"createdBy",
"createdAt",
"resolvedReports"
"createdAt"
],
"properties": {
"id": { "type": "integer" },
"action": { "type": "ref", "ref": "#actionType" },
"durationInHours": {
"type": "integer",
"description": "Indicates how long this action is meant to be in effect before automatically expiring."
"event": {
"type": "union",
"refs": [
"#modEventTakedown",
"#modEventReverseTakedown",
"#modEventComment",
"#modEventReport",
"#modEventLabel",
"#modEventAcknowledge",
"#modEventEscalate",
"#modEventMute"
]
},
"subject": {
"type": "union",
@ -75,59 +85,10 @@
"type": "array",
"items": { "type": "ref", "ref": "#blobView" }
},
"createLabelVals": { "type": "array", "items": { "type": "string" } },
"negateLabelVals": { "type": "array", "items": { "type": "string" } },
"reason": { "type": "string" },
"createdBy": { "type": "string", "format": "did" },
"createdAt": { "type": "string", "format": "datetime" },
"reversal": { "type": "ref", "ref": "#actionReversal" },
"resolvedReports": {
"type": "array",
"items": { "type": "ref", "ref": "#reportView" }
}
}
},
"actionViewCurrent": {
"type": "object",
"required": ["id", "action"],
"properties": {
"id": { "type": "integer" },
"action": { "type": "ref", "ref": "#actionType" },
"durationInHours": {
"type": "integer",
"description": "Indicates how long this action is meant to be in effect before automatically expiring."
}
}
},
"actionReversal": {
"type": "object",
"required": ["reason", "createdBy", "createdAt"],
"properties": {
"reason": { "type": "string" },
"createdBy": { "type": "string", "format": "did" },
"createdAt": { "type": "string", "format": "datetime" }
}
},
"actionType": {
"type": "string",
"knownValues": ["#takedown", "#flag", "#acknowledge", "#escalate"]
},
"takedown": {
"type": "token",
"description": "Moderation action type: Takedown. Indicates that content should not be served by the PDS."
},
"flag": {
"type": "token",
"description": "Moderation action type: Flag. Indicates that the content was reviewed and considered to violate PDS rules, but may still be served."
},
"acknowledge": {
"type": "token",
"description": "Moderation action type: Acknowledge. Indicates that the content was reviewed and not considered to violate PDS rules."
},
"escalate": {
"type": "token",
"description": "Moderation action type: Escalate. Indicates that the content has been flagged for additional review."
},
"reportView": {
"type": "object",
"required": [
@ -144,7 +105,7 @@
"type": "ref",
"ref": "com.atproto.moderation.defs#reasonType"
},
"reason": { "type": "string" },
"comment": { "type": "string" },
"subjectRepoHandle": { "type": "string" },
"subject": {
"type": "union",
@ -158,6 +119,63 @@
}
}
},
"subjectStatusView": {
"type": "object",
"required": ["id", "subject", "createdAt", "updatedAt", "reviewState"],
"properties": {
"id": { "type": "integer" },
"subject": {
"type": "union",
"refs": ["#repoRef", "com.atproto.repo.strongRef"]
},
"subjectBlobCids": {
"type": "array",
"items": { "type": "string", "format": "cid" }
},
"subjectRepoHandle": { "type": "string" },
"updatedAt": {
"type": "string",
"format": "datetime",
"description": "Timestamp referencing when the last update was made to the moderation status of the subject"
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Timestamp referencing the first moderation status impacting event was emitted on the subject"
},
"reviewState": {
"type": "ref",
"ref": "#subjectReviewState"
},
"comment": {
"type": "string",
"description": "Sticky comment on the subject."
},
"muteUntil": {
"type": "string",
"format": "datetime"
},
"lastReviewedBy": {
"type": "string",
"format": "did"
},
"lastReviewedAt": {
"type": "string",
"format": "datetime"
},
"lastReportedAt": {
"type": "string",
"format": "datetime"
},
"takendown": {
"type": "boolean"
},
"suspendUntil": {
"type": "string",
"format": "datetime"
}
}
},
"reportViewDetail": {
"type": "object",
"required": [
@ -174,7 +192,7 @@
"type": "ref",
"ref": "com.atproto.moderation.defs#reasonType"
},
"reason": { "type": "string" },
"comment": { "type": "string" },
"subject": {
"type": "union",
"refs": [
@ -184,11 +202,18 @@
"#recordViewNotFound"
]
},
"subjectStatus": {
"type": "ref",
"ref": "com.atproto.admin.defs#subjectStatusView"
},
"reportedBy": { "type": "string", "format": "did" },
"createdAt": { "type": "string", "format": "datetime" },
"resolvedByActions": {
"type": "array",
"items": { "type": "ref", "ref": "com.atproto.admin.defs#actionView" }
"items": {
"type": "ref",
"ref": "com.atproto.admin.defs#modEventView"
}
}
}
},
@ -361,21 +386,15 @@
"moderation": {
"type": "object",
"properties": {
"currentAction": { "type": "ref", "ref": "#actionViewCurrent" }
"subjectStatus": { "type": "ref", "ref": "#subjectStatusView" }
}
},
"moderationDetail": {
"type": "object",
"required": ["actions", "reports"],
"properties": {
"currentAction": { "type": "ref", "ref": "#actionViewCurrent" },
"actions": {
"type": "array",
"items": { "type": "ref", "ref": "#actionView" }
},
"reports": {
"type": "array",
"items": { "type": "ref", "ref": "#reportView" }
"subjectStatus": {
"type": "ref",
"ref": "#subjectStatusView"
}
}
},
@ -410,6 +429,136 @@
"height": { "type": "integer" },
"length": { "type": "integer" }
}
},
"subjectReviewState": {
"type": "string",
"knownValues": ["#reviewOpen", "#reviewEscalated", "#reviewClosed"]
},
"reviewOpen": {
"type": "token",
"description": "Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator"
},
"reviewEscalated": {
"type": "token",
"description": "Moderator review status of a subject: Escalated. Indicates that the subject was escalated for review by a moderator"
},
"reviewClosed": {
"type": "token",
"description": "Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator"
},
"modEventTakedown": {
"type": "object",
"description": "Take down a subject permanently or temporarily",
"properties": {
"comment": {
"type": "string"
},
"durationInHours": {
"type": "integer",
"description": "Indicates how long the takedown should be in effect before automatically expiring."
}
}
},
"modEventReverseTakedown": {
"type": "object",
"description": "Revert take down action on a subject",
"properties": {
"comment": {
"type": "string",
"description": "Describe reasoning behind the reversal."
}
}
},
"modEventComment": {
"type": "object",
"description": "Add a comment to a subject",
"required": ["comment"],
"properties": {
"comment": {
"type": "string"
},
"sticky": {
"type": "boolean",
"description": "Make the comment persistent on the subject"
}
}
},
"modEventReport": {
"type": "object",
"description": "Report a subject",
"required": ["reportType"],
"properties": {
"comment": {
"type": "string"
},
"reportType": {
"type": "ref",
"ref": "com.atproto.moderation.defs#reasonType"
}
}
},
"modEventLabel": {
"type": "object",
"description": "Apply/Negate labels on a subject",
"required": ["createLabelVals", "negateLabelVals"],
"properties": {
"comment": {
"type": "string"
},
"createLabelVals": {
"type": "array",
"items": { "type": "string" }
},
"negateLabelVals": {
"type": "array",
"items": { "type": "string" }
}
}
},
"modEventAcknowledge": {
"type": "object",
"properties": {
"comment": { "type": "string" }
}
},
"modEventEscalate": {
"type": "object",
"properties": {
"comment": { "type": "string" }
}
},
"modEventMute": {
"type": "object",
"description": "Mute incoming reports on a subject",
"required": ["durationInHours"],
"properties": {
"comment": { "type": "string" },
"durationInHours": {
"type": "integer",
"description": "Indicates how long the subject should remain muted."
}
}
},
"modEventUnmute": {
"type": "object",
"description": "Unmute action on a subject",
"properties": {
"comment": {
"type": "string",
"description": "Describe reasoning behind the reversal."
}
}
},
"modEventEmail": {
"type": "object",
"description": "Keep a log of outgoing email to a user",
"required": ["subjectLine"],
"properties": {
"subjectLine": {
"type": "string",
"description": "The subject line of the email sent to the user."
}
}
}
}
}

@ -1,6 +1,6 @@
{
"lexicon": 1,
"id": "com.atproto.admin.takeModerationAction",
"id": "com.atproto.admin.emitModerationEvent",
"defs": {
"main": {
"type": "procedure",
@ -9,14 +9,21 @@
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["action", "subject", "reason", "createdBy"],
"required": ["event", "subject", "createdBy"],
"properties": {
"action": {
"type": "string",
"knownValues": [
"com.atproto.admin.defs#takedown",
"com.atproto.admin.defs#flag",
"com.atproto.admin.defs#acknowledge"
"event": {
"type": "union",
"refs": [
"com.atproto.admin.defs#modEventTakedown",
"com.atproto.admin.defs#modEventAcknowledge",
"com.atproto.admin.defs#modEventEscalate",
"com.atproto.admin.defs#modEventComment",
"com.atproto.admin.defs#modEventLabel",
"com.atproto.admin.defs#modEventReport",
"com.atproto.admin.defs#modEventMute",
"com.atproto.admin.defs#modEventReverseTakedown",
"com.atproto.admin.defs#modEventUnmute",
"com.atproto.admin.defs#modEventEmail"
]
},
"subject": {
@ -30,19 +37,6 @@
"type": "array",
"items": { "type": "string", "format": "cid" }
},
"createLabelVals": {
"type": "array",
"items": { "type": "string" }
},
"negateLabelVals": {
"type": "array",
"items": { "type": "string" }
},
"reason": { "type": "string" },
"durationInHours": {
"type": "integer",
"description": "Indicates how long this action is meant to be in effect before automatically expiring."
},
"createdBy": { "type": "string", "format": "did" }
}
}
@ -51,7 +45,7 @@
"encoding": "application/json",
"schema": {
"type": "ref",
"ref": "com.atproto.admin.defs#actionView"
"ref": "com.atproto.admin.defs#modEventView"
}
},
"errors": [{ "name": "SubjectHasAction" }]

@ -1,40 +0,0 @@
{
"lexicon": 1,
"id": "com.atproto.admin.getModerationActions",
"defs": {
"main": {
"type": "query",
"description": "Get a list of moderation actions related to a subject.",
"parameters": {
"type": "params",
"properties": {
"subject": { "type": "string" },
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 50
},
"cursor": { "type": "string" }
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["actions"],
"properties": {
"cursor": { "type": "string" },
"actions": {
"type": "array",
"items": {
"type": "ref",
"ref": "com.atproto.admin.defs#actionView"
}
}
}
}
}
}
}
}

@ -1,10 +1,10 @@
{
"lexicon": 1,
"id": "com.atproto.admin.getModerationAction",
"id": "com.atproto.admin.getModerationEvent",
"defs": {
"main": {
"type": "query",
"description": "Get details about a moderation action.",
"description": "Get details about a moderation event.",
"parameters": {
"type": "params",
"required": ["id"],
@ -16,7 +16,7 @@
"encoding": "application/json",
"schema": {
"type": "ref",
"ref": "com.atproto.admin.defs#actionViewDetail"
"ref": "com.atproto.admin.defs#modEventViewDetail"
}
}
}

@ -1,24 +0,0 @@
{
"lexicon": 1,
"id": "com.atproto.admin.getModerationReport",
"defs": {
"main": {
"type": "query",
"description": "Get details about a moderation report.",
"parameters": {
"type": "params",
"required": ["id"],
"properties": {
"id": { "type": "integer" }
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "ref",
"ref": "com.atproto.admin.defs#reportViewDetail"
}
}
}
}
}

@ -1,65 +0,0 @@
{
"lexicon": 1,
"id": "com.atproto.admin.getModerationReports",
"defs": {
"main": {
"type": "query",
"description": "Get moderation reports related to a subject.",
"parameters": {
"type": "params",
"properties": {
"subject": { "type": "string" },
"ignoreSubjects": { "type": "array", "items": { "type": "string" } },
"actionedBy": {
"type": "string",
"format": "did",
"description": "Get all reports that were actioned by a specific moderator."
},
"reporters": {
"type": "array",
"items": { "type": "string" },
"description": "Filter reports made by one or more DIDs."
},
"resolved": { "type": "boolean" },
"actionType": {
"type": "string",
"knownValues": [
"com.atproto.admin.defs#takedown",
"com.atproto.admin.defs#flag",
"com.atproto.admin.defs#acknowledge",
"com.atproto.admin.defs#escalate"
]
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 50
},
"cursor": { "type": "string" },
"reverse": {
"type": "boolean",
"description": "Reverse the order of the returned records. When true, returns reports in chronological order."
}
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["reports"],
"properties": {
"cursor": { "type": "string" },
"reports": {
"type": "array",
"items": {
"type": "ref",
"ref": "com.atproto.admin.defs#reportView"
}
}
}
}
}
}
}
}

@ -0,0 +1,60 @@
{
"lexicon": 1,
"id": "com.atproto.admin.queryModerationEvents",
"defs": {
"main": {
"type": "query",
"description": "List moderation events related to a subject.",
"parameters": {
"type": "params",
"properties": {
"types": {
"type": "array",
"items": { "type": "string" },
"description": "The types of events (fully qualified string in the format of com.atproto.admin#modEvent<name>) to filter by. If not specified, all events are returned."
},
"createdBy": {
"type": "string",
"format": "did"
},
"sortDirection": {
"type": "string",
"default": "desc",
"enum": ["asc", "desc"],
"description": "Sort direction for the events. Defaults to descending order of created at timestamp."
},
"subject": { "type": "string", "format": "uri" },
"includeAllUserRecords": {
"type": "boolean",
"default": false,
"description": "If true, events on all record types (posts, lists, profile etc.) owned by the did are returned"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 50
},
"cursor": { "type": "string" }
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["events"],
"properties": {
"cursor": { "type": "string" },
"events": {
"type": "array",
"items": {
"type": "ref",
"ref": "com.atproto.admin.defs#modEventView"
}
}
}
}
}
}
}
}

@ -0,0 +1,95 @@
{
"lexicon": 1,
"id": "com.atproto.admin.queryModerationStatuses",
"defs": {
"main": {
"type": "query",
"description": "View moderation statuses of subjects (record or repo).",
"parameters": {
"type": "params",
"properties": {
"subject": { "type": "string", "format": "uri" },
"comment": {
"type": "string",
"description": "Search subjects by keyword from comments"
},
"reportedAfter": {
"type": "string",
"format": "datetime",
"description": "Search subjects reported after a given timestamp"
},
"reportedBefore": {
"type": "string",
"format": "datetime",
"description": "Search subjects reported before a given timestamp"
},
"reviewedAfter": {
"type": "string",
"format": "datetime",
"description": "Search subjects reviewed after a given timestamp"
},
"reviewedBefore": {
"type": "string",
"format": "datetime",
"description": "Search subjects reviewed before a given timestamp"
},
"includeMuted": {
"type": "boolean",
"description": "By default, we don't include muted subjects in the results. Set this to true to include them."
},
"reviewState": {
"type": "string",
"description": "Specify when fetching subjects in a certain state"
},
"ignoreSubjects": {
"type": "array",
"items": { "type": "string", "format": "uri" }
},
"lastReviewedBy": {
"type": "string",
"format": "did",
"description": "Get all subject statuses that were reviewed by a specific moderator"
},
"sortField": {
"type": "string",
"default": "lastReportedAt",
"enum": ["lastReviewedAt", "lastReportedAt"]
},
"sortDirection": {
"type": "string",
"default": "desc",
"enum": ["asc", "desc"]
},
"takendown": {
"type": "boolean",
"description": "Get subjects that were taken down"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 50
},
"cursor": { "type": "string" }
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["subjectStatuses"],
"properties": {
"cursor": { "type": "string" },
"subjectStatuses": {
"type": "array",
"items": {
"type": "ref",
"ref": "com.atproto.admin.defs#subjectStatusView"
}
}
}
}
}
}
}
}

@ -1,29 +0,0 @@
{
"lexicon": 1,
"id": "com.atproto.admin.resolveModerationReports",
"defs": {
"main": {
"type": "procedure",
"description": "Resolve moderation reports by an action.",
"input": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["actionId", "reportIds", "createdBy"],
"properties": {
"actionId": { "type": "integer" },
"reportIds": { "type": "array", "items": { "type": "integer" } },
"createdBy": { "type": "string", "format": "did" }
}
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "ref",
"ref": "com.atproto.admin.defs#actionView"
}
}
}
}
}

@ -1,29 +0,0 @@
{
"lexicon": 1,
"id": "com.atproto.admin.reverseModerationAction",
"defs": {
"main": {
"type": "procedure",
"description": "Reverse a moderation action.",
"input": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["id", "reason", "createdBy"],
"properties": {
"id": { "type": "integer" },
"reason": { "type": "string" },
"createdBy": { "type": "string", "format": "did" }
}
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "ref",
"ref": "com.atproto.admin.defs#actionView"
}
}
}
}
}

@ -9,11 +9,12 @@
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["recipientDid", "content"],
"required": ["recipientDid", "content", "senderDid"],
"properties": {
"recipientDid": { "type": "string", "format": "did" },
"content": { "type": "string" },
"subject": { "type": "string" }
"subject": { "type": "string" },
"senderDid": { "type": "string", "format": "did" }
}
}
},

@ -1,6 +1,6 @@
{
"name": "@atproto/api",
"version": "0.6.23",
"version": "0.6.24-next.1",
"license": "MIT",
"description": "Client library for atproto and Bluesky",
"keywords": [

@ -10,21 +10,18 @@ import { CID } from 'multiformats/cid'
import * as ComAtprotoAdminDefs from './types/com/atproto/admin/defs'
import * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites'
import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes'
import * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent'
import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites'
import * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo'
import * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes'
import * as ComAtprotoAdminGetModerationAction from './types/com/atproto/admin/getModerationAction'
import * as ComAtprotoAdminGetModerationActions from './types/com/atproto/admin/getModerationActions'
import * as ComAtprotoAdminGetModerationReport from './types/com/atproto/admin/getModerationReport'
import * as ComAtprotoAdminGetModerationReports from './types/com/atproto/admin/getModerationReports'
import * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/getModerationEvent'
import * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord'
import * as ComAtprotoAdminGetRepo from './types/com/atproto/admin/getRepo'
import * as ComAtprotoAdminGetSubjectStatus from './types/com/atproto/admin/getSubjectStatus'
import * as ComAtprotoAdminResolveModerationReports from './types/com/atproto/admin/resolveModerationReports'
import * as ComAtprotoAdminReverseModerationAction from './types/com/atproto/admin/reverseModerationAction'
import * as ComAtprotoAdminQueryModerationEvents from './types/com/atproto/admin/queryModerationEvents'
import * as ComAtprotoAdminQueryModerationStatuses from './types/com/atproto/admin/queryModerationStatuses'
import * as ComAtprotoAdminSearchRepos from './types/com/atproto/admin/searchRepos'
import * as ComAtprotoAdminSendEmail from './types/com/atproto/admin/sendEmail'
import * as ComAtprotoAdminTakeModerationAction from './types/com/atproto/admin/takeModerationAction'
import * as ComAtprotoAdminUpdateAccountEmail from './types/com/atproto/admin/updateAccountEmail'
import * as ComAtprotoAdminUpdateAccountHandle from './types/com/atproto/admin/updateAccountHandle'
import * as ComAtprotoAdminUpdateSubjectStatus from './types/com/atproto/admin/updateSubjectStatus'
@ -148,21 +145,18 @@ import * as AppBskyUnspeccedSearchPostsSkeleton from './types/app/bsky/unspecced
export * as ComAtprotoAdminDefs from './types/com/atproto/admin/defs'
export * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites'
export * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes'
export * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent'
export * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites'
export * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo'
export * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes'
export * as ComAtprotoAdminGetModerationAction from './types/com/atproto/admin/getModerationAction'
export * as ComAtprotoAdminGetModerationActions from './types/com/atproto/admin/getModerationActions'
export * as ComAtprotoAdminGetModerationReport from './types/com/atproto/admin/getModerationReport'
export * as ComAtprotoAdminGetModerationReports from './types/com/atproto/admin/getModerationReports'
export * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/getModerationEvent'
export * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord'
export * as ComAtprotoAdminGetRepo from './types/com/atproto/admin/getRepo'
export * as ComAtprotoAdminGetSubjectStatus from './types/com/atproto/admin/getSubjectStatus'
export * as ComAtprotoAdminResolveModerationReports from './types/com/atproto/admin/resolveModerationReports'
export * as ComAtprotoAdminReverseModerationAction from './types/com/atproto/admin/reverseModerationAction'
export * as ComAtprotoAdminQueryModerationEvents from './types/com/atproto/admin/queryModerationEvents'
export * as ComAtprotoAdminQueryModerationStatuses from './types/com/atproto/admin/queryModerationStatuses'
export * as ComAtprotoAdminSearchRepos from './types/com/atproto/admin/searchRepos'
export * as ComAtprotoAdminSendEmail from './types/com/atproto/admin/sendEmail'
export * as ComAtprotoAdminTakeModerationAction from './types/com/atproto/admin/takeModerationAction'
export * as ComAtprotoAdminUpdateAccountEmail from './types/com/atproto/admin/updateAccountEmail'
export * as ComAtprotoAdminUpdateAccountHandle from './types/com/atproto/admin/updateAccountHandle'
export * as ComAtprotoAdminUpdateSubjectStatus from './types/com/atproto/admin/updateSubjectStatus'
@ -284,10 +278,9 @@ export * as AppBskyUnspeccedSearchActorsSkeleton from './types/app/bsky/unspecce
export * as AppBskyUnspeccedSearchPostsSkeleton from './types/app/bsky/unspecced/searchPostsSkeleton'
export const COM_ATPROTO_ADMIN = {
DefsTakedown: 'com.atproto.admin.defs#takedown',
DefsFlag: 'com.atproto.admin.defs#flag',
DefsAcknowledge: 'com.atproto.admin.defs#acknowledge',
DefsEscalate: 'com.atproto.admin.defs#escalate',
DefsReviewOpen: 'com.atproto.admin.defs#reviewOpen',
DefsReviewEscalated: 'com.atproto.admin.defs#reviewEscalated',
DefsReviewClosed: 'com.atproto.admin.defs#reviewClosed',
}
export const COM_ATPROTO_MODERATION = {
DefsReasonSpam: 'com.atproto.moderation.defs#reasonSpam',
@ -395,6 +388,17 @@ export class AdminNS {
})
}
emitModerationEvent(
data?: ComAtprotoAdminEmitModerationEvent.InputSchema,
opts?: ComAtprotoAdminEmitModerationEvent.CallOptions,
): Promise<ComAtprotoAdminEmitModerationEvent.Response> {
return this._service.xrpc
.call('com.atproto.admin.emitModerationEvent', opts?.qp, data, opts)
.catch((e) => {
throw ComAtprotoAdminEmitModerationEvent.toKnownErr(e)
})
}
enableAccountInvites(
data?: ComAtprotoAdminEnableAccountInvites.InputSchema,
opts?: ComAtprotoAdminEnableAccountInvites.CallOptions,
@ -428,47 +432,14 @@ export class AdminNS {
})
}
getModerationAction(
params?: ComAtprotoAdminGetModerationAction.QueryParams,
opts?: ComAtprotoAdminGetModerationAction.CallOptions,
): Promise<ComAtprotoAdminGetModerationAction.Response> {
getModerationEvent(
params?: ComAtprotoAdminGetModerationEvent.QueryParams,
opts?: ComAtprotoAdminGetModerationEvent.CallOptions,
): Promise<ComAtprotoAdminGetModerationEvent.Response> {
return this._service.xrpc
.call('com.atproto.admin.getModerationAction', params, undefined, opts)
.call('com.atproto.admin.getModerationEvent', params, undefined, opts)
.catch((e) => {
throw ComAtprotoAdminGetModerationAction.toKnownErr(e)
})
}
getModerationActions(
params?: ComAtprotoAdminGetModerationActions.QueryParams,
opts?: ComAtprotoAdminGetModerationActions.CallOptions,
): Promise<ComAtprotoAdminGetModerationActions.Response> {
return this._service.xrpc
.call('com.atproto.admin.getModerationActions', params, undefined, opts)
.catch((e) => {
throw ComAtprotoAdminGetModerationActions.toKnownErr(e)
})
}
getModerationReport(
params?: ComAtprotoAdminGetModerationReport.QueryParams,
opts?: ComAtprotoAdminGetModerationReport.CallOptions,
): Promise<ComAtprotoAdminGetModerationReport.Response> {
return this._service.xrpc
.call('com.atproto.admin.getModerationReport', params, undefined, opts)
.catch((e) => {
throw ComAtprotoAdminGetModerationReport.toKnownErr(e)
})
}
getModerationReports(
params?: ComAtprotoAdminGetModerationReports.QueryParams,
opts?: ComAtprotoAdminGetModerationReports.CallOptions,
): Promise<ComAtprotoAdminGetModerationReports.Response> {
return this._service.xrpc
.call('com.atproto.admin.getModerationReports', params, undefined, opts)
.catch((e) => {
throw ComAtprotoAdminGetModerationReports.toKnownErr(e)
throw ComAtprotoAdminGetModerationEvent.toKnownErr(e)
})
}
@ -505,25 +476,30 @@ export class AdminNS {
})
}
resolveModerationReports(
data?: ComAtprotoAdminResolveModerationReports.InputSchema,
opts?: ComAtprotoAdminResolveModerationReports.CallOptions,
): Promise<ComAtprotoAdminResolveModerationReports.Response> {
queryModerationEvents(
params?: ComAtprotoAdminQueryModerationEvents.QueryParams,
opts?: ComAtprotoAdminQueryModerationEvents.CallOptions,
): Promise<ComAtprotoAdminQueryModerationEvents.Response> {
return this._service.xrpc
.call('com.atproto.admin.resolveModerationReports', opts?.qp, data, opts)
.call('com.atproto.admin.queryModerationEvents', params, undefined, opts)
.catch((e) => {
throw ComAtprotoAdminResolveModerationReports.toKnownErr(e)
throw ComAtprotoAdminQueryModerationEvents.toKnownErr(e)
})
}
reverseModerationAction(
data?: ComAtprotoAdminReverseModerationAction.InputSchema,
opts?: ComAtprotoAdminReverseModerationAction.CallOptions,
): Promise<ComAtprotoAdminReverseModerationAction.Response> {
queryModerationStatuses(
params?: ComAtprotoAdminQueryModerationStatuses.QueryParams,
opts?: ComAtprotoAdminQueryModerationStatuses.CallOptions,
): Promise<ComAtprotoAdminQueryModerationStatuses.Response> {
return this._service.xrpc
.call('com.atproto.admin.reverseModerationAction', opts?.qp, data, opts)
.call(
'com.atproto.admin.queryModerationStatuses',
params,
undefined,
opts,
)
.catch((e) => {
throw ComAtprotoAdminReverseModerationAction.toKnownErr(e)
throw ComAtprotoAdminQueryModerationStatuses.toKnownErr(e)
})
}
@ -549,17 +525,6 @@ export class AdminNS {
})
}
takeModerationAction(
data?: ComAtprotoAdminTakeModerationAction.InputSchema,
opts?: ComAtprotoAdminTakeModerationAction.CallOptions,
): Promise<ComAtprotoAdminTakeModerationAction.Response> {
return this._service.xrpc
.call('com.atproto.admin.takeModerationAction', opts?.qp, data, opts)
.catch((e) => {
throw ComAtprotoAdminTakeModerationAction.toKnownErr(e)
})
}
updateAccountEmail(
data?: ComAtprotoAdminUpdateAccountEmail.InputSchema,
opts?: ComAtprotoAdminUpdateAccountEmail.CallOptions,

File diff suppressed because it is too large Load Diff

@ -28,43 +28,55 @@ export function validateStatusAttr(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#statusAttr', v)
}
export interface ActionView {
export interface ModEventView {
id: number
action: ActionType
/** Indicates how long this action is meant to be in effect before automatically expiring. */
durationInHours?: number
event:
| ModEventTakedown
| ModEventReverseTakedown
| ModEventComment
| ModEventReport
| ModEventLabel
| ModEventAcknowledge
| ModEventEscalate
| ModEventMute
| ModEventEmail
| { $type: string; [k: string]: unknown }
subject:
| RepoRef
| ComAtprotoRepoStrongRef.Main
| { $type: string; [k: string]: unknown }
subjectBlobCids: string[]
createLabelVals?: string[]
negateLabelVals?: string[]
reason: string
createdBy: string
createdAt: string
reversal?: ActionReversal
resolvedReportIds: number[]
creatorHandle?: string
subjectHandle?: string
[k: string]: unknown
}
export function isActionView(v: unknown): v is ActionView {
export function isModEventView(v: unknown): v is ModEventView {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#actionView'
v.$type === 'com.atproto.admin.defs#modEventView'
)
}
export function validateActionView(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#actionView', v)
export function validateModEventView(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventView', v)
}
export interface ActionViewDetail {
export interface ModEventViewDetail {
id: number
action: ActionType
/** Indicates how long this action is meant to be in effect before automatically expiring. */
durationInHours?: number
event:
| ModEventTakedown
| ModEventReverseTakedown
| ModEventComment
| ModEventReport
| ModEventLabel
| ModEventAcknowledge
| ModEventEscalate
| ModEventMute
| { $type: string; [k: string]: unknown }
subject:
| RepoView
| RepoViewNotFound
@ -72,87 +84,27 @@ export interface ActionViewDetail {
| RecordViewNotFound
| { $type: string; [k: string]: unknown }
subjectBlobs: BlobView[]
createLabelVals?: string[]
negateLabelVals?: string[]
reason: string
createdBy: string
createdAt: string
reversal?: ActionReversal
resolvedReports: ReportView[]
[k: string]: unknown
}
export function isActionViewDetail(v: unknown): v is ActionViewDetail {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#actionViewDetail'
)
}
export function validateActionViewDetail(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#actionViewDetail', v)
}
export interface ActionViewCurrent {
id: number
action: ActionType
/** Indicates how long this action is meant to be in effect before automatically expiring. */
durationInHours?: number
[k: string]: unknown
}
export function isActionViewCurrent(v: unknown): v is ActionViewCurrent {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#actionViewCurrent'
)
}
export function validateActionViewCurrent(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#actionViewCurrent', v)
}
export interface ActionReversal {
reason: string
createdBy: string
createdAt: string
[k: string]: unknown
}
export function isActionReversal(v: unknown): v is ActionReversal {
export function isModEventViewDetail(v: unknown): v is ModEventViewDetail {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#actionReversal'
v.$type === 'com.atproto.admin.defs#modEventViewDetail'
)
}
export function validateActionReversal(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#actionReversal', v)
export function validateModEventViewDetail(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventViewDetail', v)
}
export type ActionType =
| 'lex:com.atproto.admin.defs#takedown'
| 'lex:com.atproto.admin.defs#flag'
| 'lex:com.atproto.admin.defs#acknowledge'
| 'lex:com.atproto.admin.defs#escalate'
| (string & {})
/** Moderation action type: Takedown. Indicates that content should not be served by the PDS. */
export const TAKEDOWN = 'com.atproto.admin.defs#takedown'
/** Moderation action type: Flag. Indicates that the content was reviewed and considered to violate PDS rules, but may still be served. */
export const FLAG = 'com.atproto.admin.defs#flag'
/** Moderation action type: Acknowledge. Indicates that the content was reviewed and not considered to violate PDS rules. */
export const ACKNOWLEDGE = 'com.atproto.admin.defs#acknowledge'
/** Moderation action type: Escalate. Indicates that the content has been flagged for additional review. */
export const ESCALATE = 'com.atproto.admin.defs#escalate'
export interface ReportView {
id: number
reasonType: ComAtprotoModerationDefs.ReasonType
reason?: string
comment?: string
subjectRepoHandle?: string
subject:
| RepoRef
@ -176,19 +128,56 @@ export function validateReportView(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#reportView', v)
}
export interface SubjectStatusView {
id: number
subject:
| RepoRef
| ComAtprotoRepoStrongRef.Main
| { $type: string; [k: string]: unknown }
subjectBlobCids?: string[]
subjectRepoHandle?: string
/** Timestamp referencing when the last update was made to the moderation status of the subject */
updatedAt: string
/** Timestamp referencing the first moderation status impacting event was emitted on the subject */
createdAt: string
reviewState: SubjectReviewState
/** Sticky comment on the subject. */
comment?: string
muteUntil?: string
lastReviewedBy?: string
lastReviewedAt?: string
lastReportedAt?: string
takendown?: boolean
suspendUntil?: string
[k: string]: unknown
}
export function isSubjectStatusView(v: unknown): v is SubjectStatusView {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#subjectStatusView'
)
}
export function validateSubjectStatusView(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#subjectStatusView', v)
}
export interface ReportViewDetail {
id: number
reasonType: ComAtprotoModerationDefs.ReasonType
reason?: string
comment?: string
subject:
| RepoView
| RepoViewNotFound
| RecordView
| RecordViewNotFound
| { $type: string; [k: string]: unknown }
subjectStatus?: SubjectStatusView
reportedBy: string
createdAt: string
resolvedByActions: ActionView[]
resolvedByActions: ModEventView[]
[k: string]: unknown
}
@ -400,7 +389,7 @@ export function validateRecordViewNotFound(v: unknown): ValidationResult {
}
export interface Moderation {
currentAction?: ActionViewCurrent
subjectStatus?: SubjectStatusView
[k: string]: unknown
}
@ -417,9 +406,7 @@ export function validateModeration(v: unknown): ValidationResult {
}
export interface ModerationDetail {
currentAction?: ActionViewCurrent
actions: ActionView[]
reports: ReportView[]
subjectStatus?: SubjectStatusView
[k: string]: unknown
}
@ -496,3 +483,208 @@ export function isVideoDetails(v: unknown): v is VideoDetails {
export function validateVideoDetails(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#videoDetails', v)
}
export type SubjectReviewState =
| 'lex:com.atproto.admin.defs#reviewOpen'
| 'lex:com.atproto.admin.defs#reviewEscalated'
| 'lex:com.atproto.admin.defs#reviewClosed'
| (string & {})
/** Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator */
export const REVIEWOPEN = 'com.atproto.admin.defs#reviewOpen'
/** Moderator review status of a subject: Escalated. Indicates that the subject was escalated for review by a moderator */
export const REVIEWESCALATED = 'com.atproto.admin.defs#reviewEscalated'
/** Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator */
export const REVIEWCLOSED = 'com.atproto.admin.defs#reviewClosed'
/** Take down a subject permanently or temporarily */
export interface ModEventTakedown {
comment?: string
/** Indicates how long the takedown should be in effect before automatically expiring. */
durationInHours?: number
[k: string]: unknown
}
export function isModEventTakedown(v: unknown): v is ModEventTakedown {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventTakedown'
)
}
export function validateModEventTakedown(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventTakedown', v)
}
/** Revert take down action on a subject */
export interface ModEventReverseTakedown {
/** Describe reasoning behind the reversal. */
comment?: string
[k: string]: unknown
}
export function isModEventReverseTakedown(
v: unknown,
): v is ModEventReverseTakedown {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventReverseTakedown'
)
}
export function validateModEventReverseTakedown(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventReverseTakedown', v)
}
/** Add a comment to a subject */
export interface ModEventComment {
comment: string
/** Make the comment persistent on the subject */
sticky?: boolean
[k: string]: unknown
}
export function isModEventComment(v: unknown): v is ModEventComment {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventComment'
)
}
export function validateModEventComment(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventComment', v)
}
/** Report a subject */
export interface ModEventReport {
comment?: string
reportType: ComAtprotoModerationDefs.ReasonType
[k: string]: unknown
}
export function isModEventReport(v: unknown): v is ModEventReport {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventReport'
)
}
export function validateModEventReport(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventReport', v)
}
/** Apply/Negate labels on a subject */
export interface ModEventLabel {
comment?: string
createLabelVals: string[]
negateLabelVals: string[]
[k: string]: unknown
}
export function isModEventLabel(v: unknown): v is ModEventLabel {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventLabel'
)
}
export function validateModEventLabel(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventLabel', v)
}
export interface ModEventAcknowledge {
comment?: string
[k: string]: unknown
}
export function isModEventAcknowledge(v: unknown): v is ModEventAcknowledge {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventAcknowledge'
)
}
export function validateModEventAcknowledge(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventAcknowledge', v)
}
export interface ModEventEscalate {
comment?: string
[k: string]: unknown
}
export function isModEventEscalate(v: unknown): v is ModEventEscalate {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventEscalate'
)
}
export function validateModEventEscalate(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventEscalate', v)
}
/** Mute incoming reports on a subject */
export interface ModEventMute {
comment?: string
/** Indicates how long the subject should remain muted. */
durationInHours: number
[k: string]: unknown
}
export function isModEventMute(v: unknown): v is ModEventMute {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventMute'
)
}
export function validateModEventMute(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventMute', v)
}
/** Unmute action on a subject */
export interface ModEventUnmute {
/** Describe reasoning behind the reversal. */
comment?: string
[k: string]: unknown
}
export function isModEventUnmute(v: unknown): v is ModEventUnmute {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventUnmute'
)
}
export function validateModEventUnmute(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventUnmute', v)
}
/** Keep a log of outgoing email to a user */
export interface ModEventEmail {
/** The subject line of the email sent to the user. */
subjectLine: string
[k: string]: unknown
}
export function isModEventEmail(v: unknown): v is ModEventEmail {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventEmail'
)
}
export function validateModEventEmail(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventEmail', v)
}

@ -12,26 +12,28 @@ import * as ComAtprotoRepoStrongRef from '../repo/strongRef'
export interface QueryParams {}
export interface InputSchema {
action:
| 'com.atproto.admin.defs#takedown'
| 'com.atproto.admin.defs#flag'
| 'com.atproto.admin.defs#acknowledge'
| (string & {})
event:
| ComAtprotoAdminDefs.ModEventTakedown
| ComAtprotoAdminDefs.ModEventAcknowledge
| ComAtprotoAdminDefs.ModEventEscalate
| ComAtprotoAdminDefs.ModEventComment
| ComAtprotoAdminDefs.ModEventLabel
| ComAtprotoAdminDefs.ModEventReport
| ComAtprotoAdminDefs.ModEventMute
| ComAtprotoAdminDefs.ModEventReverseTakedown
| ComAtprotoAdminDefs.ModEventUnmute
| ComAtprotoAdminDefs.ModEventEmail
| { $type: string; [k: string]: unknown }
subject:
| ComAtprotoAdminDefs.RepoRef
| ComAtprotoRepoStrongRef.Main
| { $type: string; [k: string]: unknown }
subjectBlobCids?: string[]
createLabelVals?: string[]
negateLabelVals?: string[]
reason: string
/** Indicates how long this action is meant to be in effect before automatically expiring. */
durationInHours?: number
createdBy: string
[k: string]: unknown
}
export type OutputSchema = ComAtprotoAdminDefs.ActionView
export type OutputSchema = ComAtprotoAdminDefs.ModEventView
export interface CallOptions {
headers?: Headers

@ -13,7 +13,7 @@ export interface QueryParams {
}
export type InputSchema = undefined
export type OutputSchema = ComAtprotoAdminDefs.ActionViewDetail
export type OutputSchema = ComAtprotoAdminDefs.ModEventViewDetail
export interface CallOptions {
headers?: Headers

@ -1,32 +0,0 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { Headers, XRPCError } from '@atproto/xrpc'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { isObj, hasProp } from '../../../../util'
import { lexicons } from '../../../../lexicons'
import { CID } from 'multiformats/cid'
import * as ComAtprotoAdminDefs from './defs'
export interface QueryParams {
id: number
}
export type InputSchema = undefined
export type OutputSchema = ComAtprotoAdminDefs.ReportViewDetail
export interface CallOptions {
headers?: Headers
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}

@ -1,53 +0,0 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { Headers, XRPCError } from '@atproto/xrpc'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { isObj, hasProp } from '../../../../util'
import { lexicons } from '../../../../lexicons'
import { CID } from 'multiformats/cid'
import * as ComAtprotoAdminDefs from './defs'
export interface QueryParams {
subject?: string
ignoreSubjects?: string[]
/** Get all reports that were actioned by a specific moderator. */
actionedBy?: string
/** Filter reports made by one or more DIDs. */
reporters?: string[]
resolved?: boolean
actionType?:
| 'com.atproto.admin.defs#takedown'
| 'com.atproto.admin.defs#flag'
| 'com.atproto.admin.defs#acknowledge'
| 'com.atproto.admin.defs#escalate'
| (string & {})
limit?: number
cursor?: string
/** Reverse the order of the returned records. When true, returns reports in chronological order. */
reverse?: boolean
}
export type InputSchema = undefined
export interface OutputSchema {
cursor?: string
reports: ComAtprotoAdminDefs.ReportView[]
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}

@ -9,7 +9,14 @@ import { CID } from 'multiformats/cid'
import * as ComAtprotoAdminDefs from './defs'
export interface QueryParams {
/** The types of events (fully qualified string in the format of com.atproto.admin#modEvent<name>) to filter by. If not specified, all events are returned. */
types?: string[]
createdBy?: string
/** Sort direction for the events. Defaults to descending order of created at timestamp. */
sortDirection?: 'asc' | 'desc'
subject?: string
/** If true, events on all record types (posts, lists, profile etc.) owned by the did are returned */
includeAllUserRecords?: boolean
limit?: number
cursor?: string
}
@ -18,7 +25,7 @@ export type InputSchema = undefined
export interface OutputSchema {
cursor?: string
actions: ComAtprotoAdminDefs.ActionView[]
events: ComAtprotoAdminDefs.ModEventView[]
[k: string]: unknown
}

@ -0,0 +1,60 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { Headers, XRPCError } from '@atproto/xrpc'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { isObj, hasProp } from '../../../../util'
import { lexicons } from '../../../../lexicons'
import { CID } from 'multiformats/cid'
import * as ComAtprotoAdminDefs from './defs'
export interface QueryParams {
subject?: string
/** Search subjects by keyword from comments */
comment?: string
/** Search subjects reported after a given timestamp */
reportedAfter?: string
/** Search subjects reported before a given timestamp */
reportedBefore?: string
/** Search subjects reviewed after a given timestamp */
reviewedAfter?: string
/** Search subjects reviewed before a given timestamp */
reviewedBefore?: string
/** By default, we don't include muted subjects in the results. Set this to true to include them. */
includeMuted?: boolean
/** Specify when fetching subjects in a certain state */
reviewState?: string
ignoreSubjects?: string[]
/** Get all subject statuses that were reviewed by a specific moderator */
lastReviewedBy?: string
sortField?: 'lastReviewedAt' | 'lastReportedAt'
sortDirection?: 'asc' | 'desc'
/** Get subjects that were taken down */
takendown?: boolean
limit?: number
cursor?: string
}
export type InputSchema = undefined
export interface OutputSchema {
cursor?: string
subjectStatuses: ComAtprotoAdminDefs.SubjectStatusView[]
[k: string]: unknown
}
export interface CallOptions {
headers?: Headers
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}

@ -1,38 +0,0 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { Headers, XRPCError } from '@atproto/xrpc'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { isObj, hasProp } from '../../../../util'
import { lexicons } from '../../../../lexicons'
import { CID } from 'multiformats/cid'
import * as ComAtprotoAdminDefs from './defs'
export interface QueryParams {}
export interface InputSchema {
actionId: number
reportIds: number[]
createdBy: string
[k: string]: unknown
}
export type OutputSchema = ComAtprotoAdminDefs.ActionView
export interface CallOptions {
headers?: Headers
qp?: QueryParams
encoding: 'application/json'
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}

@ -1,38 +0,0 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { Headers, XRPCError } from '@atproto/xrpc'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { isObj, hasProp } from '../../../../util'
import { lexicons } from '../../../../lexicons'
import { CID } from 'multiformats/cid'
import * as ComAtprotoAdminDefs from './defs'
export interface QueryParams {}
export interface InputSchema {
id: number
reason: string
createdBy: string
[k: string]: unknown
}
export type OutputSchema = ComAtprotoAdminDefs.ActionView
export interface CallOptions {
headers?: Headers
qp?: QueryParams
encoding: 'application/json'
}
export interface Response {
success: boolean
headers: Headers
data: OutputSchema
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}

@ -13,6 +13,7 @@ export interface InputSchema {
recipientDid: string
content: string
subject?: string
senderDid: string
[k: string]: unknown
}

@ -6,11 +6,11 @@ import { CID } from 'multiformats/cid'
import { ensureValidDid } from '@atproto/syntax'
import { forwardStreamErrors, VerifyCidTransform } from '@atproto/common'
import { IdResolver, DidNotFoundError } from '@atproto/identity'
import { TAKEDOWN } from '../lexicon/types/com/atproto/admin/defs'
import AppContext from '../context'
import { httpLogger as log } from '../logger'
import { retryHttp } from '../util/retry'
import { Database } from '../db'
import { sql } from 'kysely'
// Resolve and verify blob from its origin host
@ -84,19 +84,14 @@ export async function resolveBlob(
idResolver: IdResolver,
) {
const cidStr = cid.toString()
const [{ pds }, takedown] = await Promise.all([
idResolver.did.resolveAtprotoData(did), // @TODO cache did info
db.db
.selectFrom('moderation_action_subject_blob')
.select('actionId')
.innerJoin(
'moderation_action',
'moderation_action.id',
'moderation_action_subject_blob.actionId',
)
.where('cid', '=', cidStr)
.where('action', '=', TAKEDOWN)
.where('reversedAt', 'is', null)
.selectFrom('moderation_subject_status')
.select('id')
.where('blobCids', '@>', sql`CAST(${JSON.stringify([cidStr])} AS JSONB)`)
.where('takendown', 'is', true)
.executeTakeFirst(),
])
if (takedown) {

@ -0,0 +1,220 @@
import { CID } from 'multiformats/cid'
import { AtUri } from '@atproto/syntax'
import {
AuthRequiredError,
InvalidRequestError,
UpstreamFailureError,
} from '@atproto/xrpc-server'
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { getSubject } from '../moderation/util'
import {
isModEventLabel,
isModEventReverseTakedown,
isModEventTakedown,
} from '../../../../lexicon/types/com/atproto/admin/defs'
import { TakedownSubjects } from '../../../../services/moderation'
import { retryHttp } from '../../../../util/retry'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.emitModerationEvent({
auth: ctx.roleVerifier,
handler: async ({ input, auth }) => {
const access = auth.credentials
const db = ctx.db.getPrimary()
const moderationService = ctx.services.moderation(db)
const { subject, createdBy, subjectBlobCids, event } = input.body
const isTakedownEvent = isModEventTakedown(event)
const isReverseTakedownEvent = isModEventReverseTakedown(event)
const isLabelEvent = isModEventLabel(event)
// apply access rules
// if less than moderator access then can not takedown an account
if (!access.moderator && isTakedownEvent && 'did' in subject) {
throw new AuthRequiredError(
'Must be a full moderator to perform an account takedown',
)
}
// if less than moderator access then can only take ack and escalation actions
if (!access.moderator && (isTakedownEvent || isReverseTakedownEvent)) {
throw new AuthRequiredError(
'Must be a full moderator to take this type of action',
)
}
// if less than moderator access then can not apply labels
if (!access.moderator && isLabelEvent) {
throw new AuthRequiredError('Must be a full moderator to label content')
}
if (isLabelEvent) {
validateLabels([
...(event.createLabelVals ?? []),
...(event.negateLabelVals ?? []),
])
}
const subjectInfo = getSubject(subject)
if (isTakedownEvent || isReverseTakedownEvent) {
const isSubjectTakendown = await moderationService.isSubjectTakendown(
subjectInfo,
)
if (isSubjectTakendown && isTakedownEvent) {
throw new InvalidRequestError(`Subject is already taken down`)
}
if (!isSubjectTakendown && isReverseTakedownEvent) {
throw new InvalidRequestError(`Subject is not taken down`)
}
}
const { result: moderationEvent, takenDown } = await db.transaction(
async (dbTxn) => {
const moderationTxn = ctx.services.moderation(dbTxn)
const labelTxn = ctx.services.label(dbTxn)
const result = await moderationTxn.logEvent({
event,
subject: subjectInfo,
subjectBlobCids:
subjectBlobCids?.map((cid) => CID.parse(cid)) ?? [],
createdBy,
})
let takenDown: TakedownSubjects | undefined
if (
result.subjectType === 'com.atproto.admin.defs#repoRef' &&
result.subjectDid
) {
// No credentials to revoke on appview
if (isTakedownEvent) {
takenDown = await moderationTxn.takedownRepo({
takedownId: result.id,
did: result.subjectDid,
})
}
if (isReverseTakedownEvent) {
await moderationTxn.reverseTakedownRepo({
did: result.subjectDid,
})
takenDown = {
subjects: [
{
$type: 'com.atproto.admin.defs#repoRef',
did: result.subjectDid,
},
],
did: result.subjectDid,
}
}
}
if (
result.subjectType === 'com.atproto.repo.strongRef' &&
result.subjectUri
) {
const blobCids = subjectBlobCids?.map((cid) => CID.parse(cid)) ?? []
if (isTakedownEvent) {
takenDown = await moderationTxn.takedownRecord({
takedownId: result.id,
uri: new AtUri(result.subjectUri),
// TODO: I think this will always be available for strongRefs?
cid: CID.parse(result.subjectCid as string),
blobCids,
})
}
if (isReverseTakedownEvent) {
await moderationTxn.reverseTakedownRecord({
uri: new AtUri(result.subjectUri),
})
takenDown = {
did: result.subjectDid,
subjects: [
{
$type: 'com.atproto.repo.strongRef',
uri: result.subjectUri,
cid: result.subjectCid ?? '',
},
...blobCids.map((cid) => ({
$type: 'com.atproto.admin.defs#repoBlobRef',
did: result.subjectDid,
cid: cid.toString(),
recordUri: result.subjectUri,
})),
],
}
}
}
if (isLabelEvent) {
await labelTxn.formatAndCreate(
ctx.cfg.labelerDid,
result.subjectUri ?? result.subjectDid,
result.subjectCid,
{
create: result.createLabelVals?.length
? result.createLabelVals.split(' ')
: undefined,
negate: result.negateLabelVals?.length
? result.negateLabelVals.split(' ')
: undefined,
},
)
}
return { result, takenDown }
},
)
if (takenDown && ctx.moderationPushAgent) {
const { did, subjects } = takenDown
if (did && subjects.length > 0) {
const agent = ctx.moderationPushAgent
const results = await Promise.allSettled(
subjects.map((subject) =>
retryHttp(() =>
agent.api.com.atproto.admin.updateSubjectStatus({
subject,
takedown: isTakedownEvent
? {
applied: true,
ref: moderationEvent.id.toString(),
}
: {
applied: false,
},
}),
),
),
)
const hadFailure = results.some((r) => r.status === 'rejected')
if (hadFailure) {
throw new UpstreamFailureError('failed to apply action on PDS')
}
}
}
return {
encoding: 'application/json',
body: await moderationService.views.event(moderationEvent),
}
},
})
}
const validateLabels = (labels: string[]) => {
for (const label of labels) {
for (const char of badChars) {
if (label.includes(char)) {
throw new InvalidRequestError(`Invalid label: ${label}`)
}
}
}
}
const badChars = [' ', ',', ';', `'`, `"`]

@ -1,44 +0,0 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { addAccountInfoToRepoView, getPdsAccountInfo } from './util'
import {
isRecordView,
isRepoView,
} from '../../../../lexicon/types/com/atproto/admin/defs'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getModerationAction({
auth: ctx.roleVerifier,
handler: async ({ params, auth }) => {
const { id } = params
const db = ctx.db.getPrimary()
const moderationService = ctx.services.moderation(db)
const result = await moderationService.getActionOrThrow(id)
const [action, accountInfo] = await Promise.all([
moderationService.views.actionDetail(result),
getPdsAccountInfo(ctx, result.subjectDid),
])
// add in pds account info if available
if (isRepoView(action.subject)) {
action.subject = addAccountInfoToRepoView(
action.subject,
accountInfo,
auth.credentials.moderator,
)
} else if (isRecordView(action.subject)) {
action.subject.repo = addAccountInfoToRepoView(
action.subject.repo,
accountInfo,
auth.credentials.moderator,
)
}
return {
encoding: 'application/json',
body: action,
}
},
})
}

@ -2,23 +2,17 @@ import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getModerationActions({
server.com.atproto.admin.getModerationEvent({
auth: ctx.roleVerifier,
handler: async ({ params }) => {
const { subject, limit = 50, cursor } = params
const { id } = params
const db = ctx.db.getPrimary()
const moderationService = ctx.services.moderation(db)
const results = await moderationService.getActions({
subject,
limit,
cursor,
})
const event = await moderationService.getEventOrThrow(id)
const eventDetail = await moderationService.views.eventDetail(event)
return {
encoding: 'application/json',
body: {
cursor: results.at(-1)?.id.toString() ?? undefined,
actions: await moderationService.views.action(results),
},
body: eventDetail,
}
},
})

@ -1,43 +0,0 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import {
isRecordView,
isRepoView,
} from '../../../../lexicon/types/com/atproto/admin/defs'
import { addAccountInfoToRepoView, getPdsAccountInfo } from './util'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getModerationReport({
auth: ctx.roleVerifier,
handler: async ({ params, auth }) => {
const { id } = params
const db = ctx.db.getPrimary()
const moderationService = ctx.services.moderation(db)
const result = await moderationService.getReportOrThrow(id)
const [report, accountInfo] = await Promise.all([
moderationService.views.reportDetail(result),
getPdsAccountInfo(ctx, result.subjectDid),
])
// add in pds account info if available
if (isRepoView(report.subject)) {
report.subject = addAccountInfoToRepoView(
report.subject,
accountInfo,
auth.credentials.moderator,
)
} else if (isRecordView(report.subject)) {
report.subject.repo = addAccountInfoToRepoView(
report.subject.repo,
accountInfo,
auth.credentials.moderator,
)
}
return {
encoding: 'application/json',
body: report,
}
},
})
}

@ -18,6 +18,7 @@ export default function (server: Server, ctx: AppContext) {
if (!result) {
throw new InvalidRequestError('Record not found', 'RecordNotFound')
}
const [record, accountInfo] = await Promise.all([
ctx.services.moderation(db).views.recordDetail(result),
getPdsAccountInfo(ctx, result.did),

@ -1,39 +1,36 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { getEventType } from '../moderation/util'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getModerationReports({
server.com.atproto.admin.queryModerationEvents({
auth: ctx.roleVerifier,
handler: async ({ params }) => {
const {
subject,
resolved,
actionType,
limit = 50,
cursor,
ignoreSubjects,
reverse = false,
reporters = [],
actionedBy,
sortDirection = 'desc',
types,
includeAllUserRecords = false,
createdBy,
} = params
const db = ctx.db.getPrimary()
const moderationService = ctx.services.moderation(db)
const results = await moderationService.getReports({
const results = await moderationService.getEvents({
types: types?.length ? types.map(getEventType) : [],
subject,
resolved,
actionType,
createdBy,
limit,
cursor,
ignoreSubjects,
reverse,
reporters,
actionedBy,
sortDirection,
includeAllUserRecords,
})
return {
encoding: 'application/json',
body: {
cursor: results.at(-1)?.id.toString() ?? undefined,
reports: await moderationService.views.report(results),
cursor: results.cursor,
events: await moderationService.views.event(results.events),
},
}
},

@ -0,0 +1,55 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { getReviewState } from '../moderation/util'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.queryModerationStatuses({
auth: ctx.roleVerifier,
handler: async ({ params }) => {
const {
subject,
takendown,
reviewState,
reviewedAfter,
reviewedBefore,
reportedAfter,
reportedBefore,
ignoreSubjects,
lastReviewedBy,
sortDirection = 'desc',
sortField = 'lastReportedAt',
includeMuted = false,
limit = 50,
cursor,
} = params
const db = ctx.db.getPrimary()
const moderationService = ctx.services.moderation(db)
const results = await moderationService.getSubjectStatuses({
reviewState: getReviewState(reviewState),
subject,
takendown,
reviewedAfter,
reviewedBefore,
reportedAfter,
reportedBefore,
includeMuted,
ignoreSubjects,
sortDirection,
lastReviewedBy,
sortField,
limit,
cursor,
})
const subjectStatuses = moderationService.views.subjectStatus(
results.statuses,
)
return {
encoding: 'application/json',
body: {
cursor: results.cursor,
subjectStatuses,
},
}
},
})
}

@ -1,24 +0,0 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.resolveModerationReports({
auth: ctx.roleVerifier,
handler: async ({ input }) => {
const db = ctx.db.getPrimary()
const moderationService = ctx.services.moderation(db)
const { actionId, reportIds, createdBy } = input.body
const moderationAction = await db.transaction(async (dbTxn) => {
const moderationTxn = ctx.services.moderation(dbTxn)
await moderationTxn.resolveReports({ reportIds, actionId, createdBy })
return await moderationTxn.getActionOrThrow(actionId)
})
return {
encoding: 'application/json',
body: await moderationService.views.action(moderationAction),
}
},
})
}

@ -1,115 +0,0 @@
import {
AuthRequiredError,
InvalidRequestError,
UpstreamFailureError,
} from '@atproto/xrpc-server'
import {
ACKNOWLEDGE,
ESCALATE,
TAKEDOWN,
} from '../../../../lexicon/types/com/atproto/admin/defs'
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { retryHttp } from '../../../../util/retry'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.reverseModerationAction({
auth: ctx.roleVerifier,
handler: async ({ input, auth }) => {
const access = auth.credentials
const db = ctx.db.getPrimary()
const moderationService = ctx.services.moderation(db)
const { id, createdBy, reason } = input.body
const { result, restored } = await db.transaction(async (dbTxn) => {
const moderationTxn = ctx.services.moderation(dbTxn)
const labelTxn = ctx.services.label(dbTxn)
const now = new Date()
const existing = await moderationTxn.getAction(id)
if (!existing) {
throw new InvalidRequestError('Moderation action does not exist')
}
if (existing.reversedAt !== null) {
throw new InvalidRequestError(
'Moderation action has already been reversed',
)
}
// apply access rules
// if less than moderator access then can only reverse ack and escalation actions
if (
!access.moderator &&
![ACKNOWLEDGE, ESCALATE].includes(existing.action)
) {
throw new AuthRequiredError(
'Must be a full moderator to reverse this type of action',
)
}
// if less than moderator access then cannot reverse takedown on an account
if (
!access.moderator &&
existing.action === TAKEDOWN &&
existing.subjectType === 'com.atproto.admin.defs#repoRef'
) {
throw new AuthRequiredError(
'Must be a full moderator to reverse an account takedown',
)
}
const { result, restored } = await moderationTxn.revertAction({
id,
createdAt: now,
createdBy,
reason,
})
// invert creates & negates
const { createLabelVals, negateLabelVals } = result
const negate =
createLabelVals && createLabelVals.length > 0
? createLabelVals.split(' ')
: undefined
const create =
negateLabelVals && negateLabelVals.length > 0
? negateLabelVals.split(' ')
: undefined
await labelTxn.formatAndCreate(
ctx.cfg.labelerDid,
result.subjectUri ?? result.subjectDid,
result.subjectCid,
{ create, negate },
)
return { result, restored }
})
if (restored && ctx.moderationPushAgent) {
const agent = ctx.moderationPushAgent
const { subjects } = restored
const results = await Promise.allSettled(
subjects.map((subject) =>
retryHttp(() =>
agent.api.com.atproto.admin.updateSubjectStatus({
subject,
takedown: {
applied: false,
},
}),
),
),
)
const hadFailure = results.some((r) => r.status === 'rejected')
if (hadFailure) {
throw new UpstreamFailureError('failed to revert action on PDS')
}
}
return {
encoding: 'application/json',
body: await moderationService.views.action(result),
}
},
})
}

@ -1,156 +0,0 @@
import { CID } from 'multiformats/cid'
import { AtUri } from '@atproto/syntax'
import {
AuthRequiredError,
InvalidRequestError,
UpstreamFailureError,
} from '@atproto/xrpc-server'
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import {
ACKNOWLEDGE,
ESCALATE,
TAKEDOWN,
} from '../../../../lexicon/types/com/atproto/admin/defs'
import { getSubject, getAction } from '../moderation/util'
import { TakedownSubjects } from '../../../../services/moderation'
import { retryHttp } from '../../../../util/retry'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.takeModerationAction({
auth: ctx.roleVerifier,
handler: async ({ input, auth }) => {
const access = auth.credentials
const db = ctx.db.getPrimary()
const moderationService = ctx.services.moderation(db)
const {
action,
subject,
reason,
createdBy,
createLabelVals,
negateLabelVals,
subjectBlobCids,
durationInHours,
} = input.body
// apply access rules
// if less than admin access then can not takedown an account
if (!access.moderator && action === TAKEDOWN && 'did' in subject) {
throw new AuthRequiredError(
'Must be a full moderator to perform an account takedown',
)
}
// if less than moderator access then can only take ack and escalation actions
if (!access.moderator && ![ACKNOWLEDGE, ESCALATE].includes(action)) {
throw new AuthRequiredError(
'Must be a full moderator to take this type of action',
)
}
// if less than moderator access then can not apply labels
if (
!access.moderator &&
(createLabelVals?.length || negateLabelVals?.length)
) {
throw new AuthRequiredError('Must be a full moderator to label content')
}
validateLabels([...(createLabelVals ?? []), ...(negateLabelVals ?? [])])
const { result, takenDown } = await db.transaction(async (dbTxn) => {
const moderationTxn = ctx.services.moderation(dbTxn)
const labelTxn = ctx.services.label(dbTxn)
const result = await moderationTxn.logAction({
action: getAction(action),
subject: getSubject(subject),
subjectBlobCids: subjectBlobCids?.map((cid) => CID.parse(cid)) ?? [],
createLabelVals,
negateLabelVals,
createdBy,
reason,
durationInHours,
})
let takenDown: TakedownSubjects | undefined
if (
result.action === TAKEDOWN &&
result.subjectType === 'com.atproto.admin.defs#repoRef' &&
result.subjectDid
) {
// No credentials to revoke on appview
takenDown = await moderationTxn.takedownRepo({
takedownId: result.id,
did: result.subjectDid,
})
}
if (
result.action === TAKEDOWN &&
result.subjectType === 'com.atproto.repo.strongRef' &&
result.subjectUri &&
result.subjectCid
) {
takenDown = await moderationTxn.takedownRecord({
takedownId: result.id,
uri: new AtUri(result.subjectUri),
cid: CID.parse(result.subjectCid),
blobCids: subjectBlobCids?.map((cid) => CID.parse(cid)) ?? [],
})
}
await labelTxn.formatAndCreate(
ctx.cfg.labelerDid,
result.subjectUri ?? result.subjectDid,
result.subjectCid,
{ create: createLabelVals, negate: negateLabelVals },
)
return { result, takenDown }
})
if (takenDown && ctx.moderationPushAgent) {
const agent = ctx.moderationPushAgent
const { did, subjects } = takenDown
if (did && subjects.length > 0) {
const results = await Promise.allSettled(
subjects.map((subject) =>
retryHttp(() =>
agent.api.com.atproto.admin.updateSubjectStatus({
subject,
takedown: {
applied: true,
ref: result.id.toString(),
},
}),
),
),
)
const hadFailure = results.some((r) => r.status === 'rejected')
if (hadFailure) {
throw new UpstreamFailureError('failed to apply action on PDS')
}
}
}
return {
encoding: 'application/json',
body: await moderationService.views.action(result),
}
},
})
}
const validateLabels = (labels: string[]) => {
for (const label of labels) {
for (const char of badChars) {
if (label.includes(char)) {
throw new InvalidRequestError(`Invalid label: ${label}`)
}
}
}
}
const badChars = [' ', ',', ';', `'`, `"`]

@ -22,15 +22,17 @@ export default function (server: Server, ctx: AppContext) {
}
}
const moderationService = ctx.services.moderation(db)
const report = await moderationService.report({
reasonType: getReasonType(reasonType),
reason,
subject: getSubject(subject),
reportedBy: requester || ctx.cfg.serverDid,
const report = await db.transaction(async (dbTxn) => {
const moderationTxn = ctx.services.moderation(dbTxn)
return moderationTxn.report({
reasonType: getReasonType(reasonType),
reason,
subject: getSubject(subject),
reportedBy: requester || ctx.cfg.serverDid,
})
})
const moderationService = ctx.services.moderation(db)
return {
encoding: 'application/json',
body: moderationService.views.reportPublic(report),

@ -1,16 +1,8 @@
import { CID } from 'multiformats/cid'
import { InvalidRequestError } from '@atproto/xrpc-server'
import { AtUri } from '@atproto/syntax'
import { ModerationAction } from '../../../../db/tables/moderation'
import { ModerationReport } from '../../../../db/tables/moderation'
import { InputSchema as ReportInput } from '../../../../lexicon/types/com/atproto/moderation/createReport'
import { InputSchema as ActionInput } from '../../../../lexicon/types/com/atproto/admin/takeModerationAction'
import {
ACKNOWLEDGE,
FLAG,
TAKEDOWN,
ESCALATE,
} from '../../../../lexicon/types/com/atproto/admin/defs'
import { InputSchema as ActionInput } from '../../../../lexicon/types/com/atproto/admin/emitModerationEvent'
import {
REASONOTHER,
REASONSPAM,
@ -19,6 +11,13 @@ import {
REASONSEXUAL,
REASONVIOLATION,
} from '../../../../lexicon/types/com/atproto/moderation/defs'
import {
REVIEWCLOSED,
REVIEWESCALATED,
REVIEWOPEN,
} from '../../../../lexicon/types/com/atproto/admin/defs'
import { ModerationEvent } from '../../../../db/tables/moderation'
import { ModerationSubjectStatusRow } from '../../../../services/moderation/types'
type SubjectInput = ReportInput['subject'] | ActionInput['subject']
@ -34,8 +33,9 @@ export const getSubject = (subject: SubjectInput) => {
typeof subject.uri === 'string' &&
typeof subject.cid === 'string'
) {
const uri = new AtUri(subject.uri)
return {
uri: new AtUri(subject.uri),
uri,
cid: CID.parse(subject.cid),
}
}
@ -44,23 +44,28 @@ export const getSubject = (subject: SubjectInput) => {
export const getReasonType = (reasonType: ReportInput['reasonType']) => {
if (reasonTypes.has(reasonType)) {
return reasonType as ModerationReport['reasonType']
return reasonType as NonNullable<ModerationEvent['meta']>['reportType']
}
throw new InvalidRequestError('Invalid reason type')
}
export const getAction = (action: ActionInput['action']) => {
if (
action === TAKEDOWN ||
action === FLAG ||
action === ACKNOWLEDGE ||
action === ESCALATE
) {
return action as ModerationAction['action']
export const getEventType = (type: string) => {
if (eventTypes.has(type)) {
return type as ModerationEvent['action']
}
throw new InvalidRequestError('Invalid action')
throw new InvalidRequestError('Invalid event type')
}
export const getReviewState = (reviewState?: string) => {
if (!reviewState) return undefined
if (reviewStates.has(reviewState)) {
return reviewState as ModerationSubjectStatusRow['reviewState']
}
throw new InvalidRequestError('Invalid review state')
}
const reviewStates = new Set([REVIEWCLOSED, REVIEWESCALATED, REVIEWOPEN])
const reasonTypes = new Set([
REASONOTHER,
REASONSPAM,
@ -69,3 +74,16 @@ const reasonTypes = new Set([
REASONSEXUAL,
REASONVIOLATION,
])
const eventTypes = new Set([
'com.atproto.admin.defs#modEventTakedown',
'com.atproto.admin.defs#modEventAcknowledge',
'com.atproto.admin.defs#modEventEscalate',
'com.atproto.admin.defs#modEventComment',
'com.atproto.admin.defs#modEventLabel',
'com.atproto.admin.defs#modEventReport',
'com.atproto.admin.defs#modEventMute',
'com.atproto.admin.defs#modEventUnmute',
'com.atproto.admin.defs#modEventReverseTakedown',
'com.atproto.admin.defs#modEventEmail',
])

@ -41,18 +41,15 @@ import registerPush from './app/bsky/notification/registerPush'
import getPopularFeedGenerators from './app/bsky/unspecced/getPopularFeedGenerators'
import getTimelineSkeleton from './app/bsky/unspecced/getTimelineSkeleton'
import createReport from './com/atproto/moderation/createReport'
import resolveModerationReports from './com/atproto/admin/resolveModerationReports'
import reverseModerationAction from './com/atproto/admin/reverseModerationAction'
import takeModerationAction from './com/atproto/admin/takeModerationAction'
import emitModerationEvent from './com/atproto/admin/emitModerationEvent'
import searchRepos from './com/atproto/admin/searchRepos'
import adminGetRecord from './com/atproto/admin/getRecord'
import getRepo from './com/atproto/admin/getRepo'
import getModerationAction from './com/atproto/admin/getModerationAction'
import getModerationActions from './com/atproto/admin/getModerationActions'
import getModerationReport from './com/atproto/admin/getModerationReport'
import getModerationReports from './com/atproto/admin/getModerationReports'
import queryModerationStatuses from './com/atproto/admin/queryModerationStatuses'
import resolveHandle from './com/atproto/identity/resolveHandle'
import getRecord from './com/atproto/repo/getRecord'
import queryModerationEvents from './com/atproto/admin/queryModerationEvents'
import getModerationEvent from './com/atproto/admin/getModerationEvent'
import fetchLabels from './com/atproto/temp/fetchLabels'
export * as health from './health'
@ -105,16 +102,13 @@ export default function (server: Server, ctx: AppContext) {
getTimelineSkeleton(server, ctx)
// com.atproto
createReport(server, ctx)
resolveModerationReports(server, ctx)
reverseModerationAction(server, ctx)
takeModerationAction(server, ctx)
emitModerationEvent(server, ctx)
searchRepos(server, ctx)
adminGetRecord(server, ctx)
getRepo(server, ctx)
getModerationAction(server, ctx)
getModerationActions(server, ctx)
getModerationReport(server, ctx)
getModerationReports(server, ctx)
getModerationEvent(server, ctx)
queryModerationEvents(server, ctx)
queryModerationStatuses(server, ctx)
resolveHandle(server, ctx)
getRecord(server, ctx)
fetchLabels(server, ctx)

@ -61,7 +61,6 @@ export class AutoModerator {
'moderation service not properly configured',
)
}
this.imgLabeler = hiveApiKey ? new HiveLabeler(hiveApiKey, ctx) : undefined
this.textLabeler = new KeywordLabeler(ctx.cfg.labelerKeywords)
if (abyssEndpoint && abyssPassword) {
@ -157,18 +156,22 @@ export class AutoModerator {
if (!this.textFlagger) return
const matches = this.textFlagger.getMatches(text)
if (matches.length < 1) return
if (!this.services.moderation) {
log.error(
{ subject, text, matches },
'no moderation service setup to flag record text',
)
return
}
await this.services.moderation(this.ctx.db).report({
reasonType: REASONOTHER,
reason: `Automatically flagged for possible slurs: ${matches.join(', ')}`,
subject,
reportedBy: this.ctx.cfg.labelerDid,
await this.ctx.db.transaction(async (dbTxn) => {
if (!this.services.moderation) {
log.error(
{ subject, text, matches },
'no moderation service setup to flag record text',
)
return
}
return this.services.moderation(dbTxn).report({
reasonType: REASONOTHER,
reason: `Automatically flagged for possible slurs: ${matches.join(
', ',
)}`,
subject,
reportedBy: this.ctx.cfg.labelerDid,
})
})
}
@ -244,15 +247,17 @@ export class AutoModerator {
}
if (this.pushAgent) {
await this.pushAgent.com.atproto.admin.takeModerationAction({
action: 'com.atproto.admin.defs#takedown',
await this.pushAgent.com.atproto.admin.emitModerationEvent({
event: {
$type: 'com.atproto.admin.defs#modEventTakedown',
comment: takedownReason,
},
subject: {
$type: 'com.atproto.repo.strongRef',
uri: uri.toString(),
cid: recordCid.toString(),
},
subjectBlobCids: takedownCids.map((c) => c.toString()),
reason: takedownReason,
createdBy: this.ctx.cfg.labelerDid,
})
} else {
@ -261,11 +266,13 @@ export class AutoModerator {
throw new Error('no mod push agent or uri invalidator setup')
}
const modSrvc = this.services.moderation(dbTxn)
const action = await modSrvc.logAction({
action: 'com.atproto.admin.defs#takedown',
const action = await modSrvc.logEvent({
event: {
$type: 'com.atproto.admin.defs#modEventTakedown',
comment: takedownReason,
},
subject: { uri, cid: recordCid },
subjectBlobCids: takedownCids,
reason: takedownReason,
createdBy: this.ctx.cfg.labelerDid,
})
await modSrvc.takedownRecord({

@ -0,0 +1,123 @@
import { Kysely } from 'kysely'
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.createTable('moderation_event')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('action', 'varchar', (col) => col.notNull())
.addColumn('subjectType', 'varchar', (col) => col.notNull())
.addColumn('subjectDid', 'varchar', (col) => col.notNull())
.addColumn('subjectUri', 'varchar')
.addColumn('subjectCid', 'varchar')
.addColumn('comment', 'text')
.addColumn('meta', 'jsonb')
.addColumn('createdAt', 'varchar', (col) => col.notNull())
.addColumn('createdBy', 'varchar', (col) => col.notNull())
.addColumn('reversedAt', 'varchar')
.addColumn('reversedBy', 'varchar')
.addColumn('durationInHours', 'integer')
.addColumn('expiresAt', 'varchar')
.addColumn('reversedReason', 'text')
.addColumn('createLabelVals', 'varchar')
.addColumn('negateLabelVals', 'varchar')
.addColumn('legacyRefId', 'integer')
.execute()
await db.schema
.createTable('moderation_subject_status')
.addColumn('id', 'serial', (col) => col.primaryKey())
// Identifiers
.addColumn('did', 'varchar', (col) => col.notNull())
// Default to '' so that we can apply unique constraints on did and recordPath columns
.addColumn('recordPath', 'varchar', (col) => col.notNull().defaultTo(''))
.addColumn('blobCids', 'jsonb')
.addColumn('recordCid', 'varchar')
// human review team state
.addColumn('reviewState', 'varchar', (col) => col.notNull())
.addColumn('comment', 'varchar')
.addColumn('muteUntil', 'varchar')
.addColumn('lastReviewedAt', 'varchar')
.addColumn('lastReviewedBy', 'varchar')
// report state
.addColumn('lastReportedAt', 'varchar')
// visibility/intervention state
.addColumn('takendown', 'boolean', (col) => col.defaultTo(false).notNull())
.addColumn('suspendUntil', 'varchar')
// timestamps
.addColumn('createdAt', 'varchar', (col) => col.notNull())
.addColumn('updatedAt', 'varchar', (col) => col.notNull())
.addUniqueConstraint('moderation_status_unique_idx', ['did', 'recordPath'])
.execute()
await db.schema
.createIndex('moderation_subject_status_blob_cids_idx')
.on('moderation_subject_status')
.using('gin')
.column('blobCids')
.execute()
// Move foreign keys from moderation_action to moderation_event
await db.schema
.alterTable('record')
.dropConstraint('record_takedown_id_fkey')
.execute()
await db.schema
.alterTable('actor')
.dropConstraint('actor_takedown_id_fkey')
.execute()
await db.schema
.alterTable('actor')
.addForeignKeyConstraint(
'actor_takedown_id_fkey',
['takedownId'],
'moderation_event',
['id'],
)
.execute()
await db.schema
.alterTable('record')
.addForeignKeyConstraint(
'record_takedown_id_fkey',
['takedownId'],
'moderation_event',
['id'],
)
.execute()
}
export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable('moderation_event').execute()
await db.schema.dropTable('moderation_subject_status').execute()
// Revert foreign key constraints
await db.schema
.alterTable('record')
.dropConstraint('record_takedown_id_fkey')
.execute()
await db.schema
.alterTable('actor')
.dropConstraint('actor_takedown_id_fkey')
.execute()
await db.schema
.alterTable('actor')
.addForeignKeyConstraint(
'actor_takedown_id_fkey',
['takedownId'],
'moderation_action',
['id'],
)
.execute()
await db.schema
.alterTable('record')
.addForeignKeyConstraint(
'record_takedown_id_fkey',
['takedownId'],
'moderation_action',
['id'],
)
.execute()
}

@ -30,3 +30,4 @@ export * as _20230904T211011773Z from './20230904T211011773Z-block-lists'
export * as _20230906T222220386Z from './20230906T222220386Z-thread-gating'
export * as _20230920T213858047Z from './20230920T213858047Z-add-tags-to-post'
export * as _20230929T192920807Z from './20230929T192920807Z-record-cursor-indexes'
export * as _20231003T202833377Z from './20231003T202833377Z-create-moderation-subject-status'

@ -117,13 +117,36 @@ export const paginate = <
direction?: 'asc' | 'desc'
keyset: K
tryIndex?: boolean
// By default, pg does nullsFirst
nullsLast?: boolean
},
): QB => {
const { limit, cursor, keyset, direction = 'desc', tryIndex } = opts
const {
limit,
cursor,
keyset,
direction = 'desc',
tryIndex,
nullsLast,
} = opts
const keysetSql = keyset.getSql(keyset.unpack(cursor), direction, tryIndex)
return qb
.if(!!limit, (q) => q.limit(limit as number))
.orderBy(keyset.primary, direction)
.orderBy(keyset.secondary, direction)
.if(!nullsLast, (q) =>
q.orderBy(keyset.primary, direction).orderBy(keyset.secondary, direction),
)
.if(!!nullsLast, (q) =>
q
.orderBy(
direction === 'asc'
? sql`${keyset.primary} asc nulls last`
: sql`${keyset.primary} desc nulls last`,
)
.orderBy(
direction === 'asc'
? sql`${keyset.secondary} asc nulls last`
: sql`${keyset.secondary} desc nulls last`,
),
)
.if(!!keysetSql, (qb) => (keysetSql ? qb.where(keysetSql) : qb)) as QB
}

@ -2,13 +2,15 @@ import { wait } from '@atproto/common'
import { Leader } from './leader'
import { dbLogger } from '../logger'
import AppContext from '../context'
import { AtUri } from '@atproto/api'
import { ModerationSubjectStatusRow } from '../services/moderation/types'
import { CID } from 'multiformats/cid'
import AtpAgent from '@atproto/api'
import { LabelService } from '../services/label'
import { ModerationActionRow } from '../services/moderation'
import { retryHttp } from '../util/retry'
export const MODERATION_ACTION_REVERSAL_ID = 1011
export class PeriodicModerationActionReversal {
export class PeriodicModerationEventReversal {
leader = new Leader(
MODERATION_ACTION_REVERSAL_ID,
this.appContext.db.getPrimary(),
@ -20,48 +22,50 @@ export class PeriodicModerationActionReversal {
this.pushAgent = appContext.moderationPushAgent
}
// invert label creation & negations
async reverseLabels(labelTxn: LabelService, actionRow: ModerationActionRow) {
let uri: string
let cid: string | null = null
if (actionRow.subjectUri && actionRow.subjectCid) {
uri = actionRow.subjectUri
cid = actionRow.subjectCid
} else {
uri = actionRow.subjectDid
}
await labelTxn.formatAndCreate(this.appContext.cfg.labelerDid, uri, cid, {
create: actionRow.negateLabelVals
? actionRow.negateLabelVals.split(' ')
: undefined,
negate: actionRow.createLabelVals
? actionRow.createLabelVals.split(' ')
: undefined,
})
}
async revertAction(actionRow: ModerationActionRow) {
const reverseAction = {
id: actionRow.id,
createdBy: actionRow.createdBy,
createdAt: new Date(),
reason: `[SCHEDULED_REVERSAL] Reverting action as originally scheduled`,
}
if (this.pushAgent) {
await this.pushAgent.com.atproto.admin.reverseModerationAction(
reverseAction,
)
return
}
async revertState(eventRow: ModerationSubjectStatusRow) {
await this.appContext.db.getPrimary().transaction(async (dbTxn) => {
const moderationTxn = this.appContext.services.moderation(dbTxn)
await moderationTxn.revertAction(reverseAction)
const labelTxn = this.appContext.services.label(dbTxn)
await this.reverseLabels(labelTxn, actionRow)
const originalEvent =
await moderationTxn.getLastReversibleEventForSubject(eventRow)
if (originalEvent) {
const { restored } = await moderationTxn.revertState({
action: originalEvent.action,
createdBy: originalEvent.createdBy,
comment:
'[SCHEDULED_REVERSAL] Reverting action as originally scheduled',
subject:
eventRow.recordPath && eventRow.recordCid
? {
uri: AtUri.make(
eventRow.did,
...eventRow.recordPath.split('/'),
),
cid: CID.parse(eventRow.recordCid),
}
: { did: eventRow.did },
createdAt: new Date(),
})
const { pushAgent } = this
if (
originalEvent.action === 'com.atproto.admin.defs#modEventTakedown' &&
restored?.subjects?.length &&
pushAgent
) {
await Promise.allSettled(
restored.subjects.map((subject) =>
retryHttp(() =>
pushAgent.api.com.atproto.admin.updateSubjectStatus({
subject,
takedown: {
applied: false,
},
}),
),
),
)
}
}
})
}
@ -69,12 +73,12 @@ export class PeriodicModerationActionReversal {
const moderationService = this.appContext.services.moderation(
this.appContext.db.getPrimary(),
)
const actionsDueForReversal =
await moderationService.getActionsDueForReversal()
const subjectsDueForReversal =
await moderationService.getSubjectsDueForReversal()
// We shouldn't have too many actions due for reversal at any given time, so running in parallel is probably fine
// Internally, each reversal runs within its own transaction
await Promise.all(actionsDueForReversal.map(this.revertAction.bind(this)))
await Promise.all(subjectsDueForReversal.map(this.revertState.bind(this)))
}
async run() {

@ -1,76 +1,59 @@
import { Generated } from 'kysely'
import {
ACKNOWLEDGE,
FLAG,
TAKEDOWN,
ESCALATE,
REVIEWCLOSED,
REVIEWOPEN,
REVIEWESCALATED,
} from '../../lexicon/types/com/atproto/admin/defs'
import {
REASONOTHER,
REASONSPAM,
REASONMISLEADING,
REASONRUDE,
REASONSEXUAL,
REASONVIOLATION,
} from '../../lexicon/types/com/atproto/moderation/defs'
export const actionTableName = 'moderation_action'
export const actionSubjectBlobTableName = 'moderation_action_subject_blob'
export const reportTableName = 'moderation_report'
export const reportResolutionTableName = 'moderation_report_resolution'
export const eventTableName = 'moderation_event'
export const subjectStatusTableName = 'moderation_subject_status'
export interface ModerationAction {
export interface ModerationEvent {
id: Generated<number>
action: typeof TAKEDOWN | typeof FLAG | typeof ACKNOWLEDGE | typeof ESCALATE
action:
| 'com.atproto.admin.defs#modEventTakedown'
| 'com.atproto.admin.defs#modEventAcknowledge'
| 'com.atproto.admin.defs#modEventEscalate'
| 'com.atproto.admin.defs#modEventComment'
| 'com.atproto.admin.defs#modEventLabel'
| 'com.atproto.admin.defs#modEventReport'
| 'com.atproto.admin.defs#modEventMute'
| 'com.atproto.admin.defs#modEventReverseTakedown'
| 'com.atproto.admin.defs#modEventEmail'
subjectType: 'com.atproto.admin.defs#repoRef' | 'com.atproto.repo.strongRef'
subjectDid: string
subjectUri: string | null
subjectCid: string | null
createLabelVals: string | null
negateLabelVals: string | null
reason: string
comment: string | null
createdAt: string
createdBy: string
reversedAt: string | null
reversedBy: string | null
reversedReason: string | null
durationInHours: number | null
expiresAt: string | null
meta: Record<string, string | boolean> | null
legacyRefId: number | null
}
export interface ModerationActionSubjectBlob {
actionId: number
cid: string
}
export interface ModerationReport {
export interface ModerationSubjectStatus {
id: Generated<number>
subjectType: 'com.atproto.admin.defs#repoRef' | 'com.atproto.repo.strongRef'
subjectDid: string
subjectUri: string | null
subjectCid: string | null
reasonType:
| typeof REASONSPAM
| typeof REASONOTHER
| typeof REASONMISLEADING
| typeof REASONRUDE
| typeof REASONSEXUAL
| typeof REASONVIOLATION
reason: string | null
reportedByDid: string
did: string
recordPath: string
recordCid: string | null
blobCids: string[] | null
reviewState: typeof REVIEWCLOSED | typeof REVIEWOPEN | typeof REVIEWESCALATED
createdAt: string
}
export interface ModerationReportResolution {
reportId: number
actionId: number
createdAt: string
createdBy: string
updatedAt: string
lastReviewedBy: string | null
lastReviewedAt: string | null
lastReportedAt: string | null
muteUntil: string | null
suspendUntil: string | null
takendown: boolean
comment: string | null
}
export type PartialDB = {
[actionTableName]: ModerationAction
[actionSubjectBlobTableName]: ModerationActionSubjectBlob
[reportTableName]: ModerationReport
[reportResolutionTableName]: ModerationReportResolution
[eventTableName]: ModerationEvent
[subjectStatusTableName]: ModerationSubjectStatus
}

@ -32,13 +32,14 @@ export type { ServerConfigValues } from './config'
export type { MountedAlgos } from './feed-gen/types'
export { ServerConfig } from './config'
export { Database, PrimaryDatabase, DatabaseCoordinator } from './db'
export { PeriodicModerationActionReversal } from './db/periodic-moderation-action-reversal'
export { PeriodicModerationEventReversal } from './db/periodic-moderation-event-reversal'
export { Redis } from './redis'
export { ViewMaintainer } from './db/views'
export { AppContext } from './context'
export { makeAlgos } from './feed-gen'
export * from './indexer'
export * from './ingester'
export { MigrateModerationData } from './migrate-moderation-data'
export class BskyAppView {
public ctx: AppContext

@ -11,21 +11,18 @@ import {
import { schemas } from './lexicons'
import * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites'
import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes'
import * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent'
import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites'
import * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo'
import * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes'
import * as ComAtprotoAdminGetModerationAction from './types/com/atproto/admin/getModerationAction'
import * as ComAtprotoAdminGetModerationActions from './types/com/atproto/admin/getModerationActions'
import * as ComAtprotoAdminGetModerationReport from './types/com/atproto/admin/getModerationReport'
import * as ComAtprotoAdminGetModerationReports from './types/com/atproto/admin/getModerationReports'
import * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/getModerationEvent'
import * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord'
import * as ComAtprotoAdminGetRepo from './types/com/atproto/admin/getRepo'
import * as ComAtprotoAdminGetSubjectStatus from './types/com/atproto/admin/getSubjectStatus'
import * as ComAtprotoAdminResolveModerationReports from './types/com/atproto/admin/resolveModerationReports'
import * as ComAtprotoAdminReverseModerationAction from './types/com/atproto/admin/reverseModerationAction'
import * as ComAtprotoAdminQueryModerationEvents from './types/com/atproto/admin/queryModerationEvents'
import * as ComAtprotoAdminQueryModerationStatuses from './types/com/atproto/admin/queryModerationStatuses'
import * as ComAtprotoAdminSearchRepos from './types/com/atproto/admin/searchRepos'
import * as ComAtprotoAdminSendEmail from './types/com/atproto/admin/sendEmail'
import * as ComAtprotoAdminTakeModerationAction from './types/com/atproto/admin/takeModerationAction'
import * as ComAtprotoAdminUpdateAccountEmail from './types/com/atproto/admin/updateAccountEmail'
import * as ComAtprotoAdminUpdateAccountHandle from './types/com/atproto/admin/updateAccountHandle'
import * as ComAtprotoAdminUpdateSubjectStatus from './types/com/atproto/admin/updateSubjectStatus'
@ -123,10 +120,9 @@ import * as AppBskyUnspeccedSearchActorsSkeleton from './types/app/bsky/unspecce
import * as AppBskyUnspeccedSearchPostsSkeleton from './types/app/bsky/unspecced/searchPostsSkeleton'
export const COM_ATPROTO_ADMIN = {
DefsTakedown: 'com.atproto.admin.defs#takedown',
DefsFlag: 'com.atproto.admin.defs#flag',
DefsAcknowledge: 'com.atproto.admin.defs#acknowledge',
DefsEscalate: 'com.atproto.admin.defs#escalate',
DefsReviewOpen: 'com.atproto.admin.defs#reviewOpen',
DefsReviewEscalated: 'com.atproto.admin.defs#reviewEscalated',
DefsReviewClosed: 'com.atproto.admin.defs#reviewClosed',
}
export const COM_ATPROTO_MODERATION = {
DefsReasonSpam: 'com.atproto.moderation.defs#reasonSpam',
@ -220,6 +216,17 @@ export class AdminNS {
return this._server.xrpc.method(nsid, cfg)
}
emitModerationEvent<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
ComAtprotoAdminEmitModerationEvent.Handler<ExtractAuth<AV>>,
ComAtprotoAdminEmitModerationEvent.HandlerReqCtx<ExtractAuth<AV>>
>,
) {
const nsid = 'com.atproto.admin.emitModerationEvent' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
enableAccountInvites<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
@ -253,47 +260,14 @@ export class AdminNS {
return this._server.xrpc.method(nsid, cfg)
}
getModerationAction<AV extends AuthVerifier>(
getModerationEvent<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
ComAtprotoAdminGetModerationAction.Handler<ExtractAuth<AV>>,
ComAtprotoAdminGetModerationAction.HandlerReqCtx<ExtractAuth<AV>>
ComAtprotoAdminGetModerationEvent.Handler<ExtractAuth<AV>>,
ComAtprotoAdminGetModerationEvent.HandlerReqCtx<ExtractAuth<AV>>
>,
) {
const nsid = 'com.atproto.admin.getModerationAction' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
getModerationActions<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
ComAtprotoAdminGetModerationActions.Handler<ExtractAuth<AV>>,
ComAtprotoAdminGetModerationActions.HandlerReqCtx<ExtractAuth<AV>>
>,
) {
const nsid = 'com.atproto.admin.getModerationActions' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
getModerationReport<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
ComAtprotoAdminGetModerationReport.Handler<ExtractAuth<AV>>,
ComAtprotoAdminGetModerationReport.HandlerReqCtx<ExtractAuth<AV>>
>,
) {
const nsid = 'com.atproto.admin.getModerationReport' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
getModerationReports<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
ComAtprotoAdminGetModerationReports.Handler<ExtractAuth<AV>>,
ComAtprotoAdminGetModerationReports.HandlerReqCtx<ExtractAuth<AV>>
>,
) {
const nsid = 'com.atproto.admin.getModerationReports' // @ts-ignore
const nsid = 'com.atproto.admin.getModerationEvent' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
@ -330,25 +304,25 @@ export class AdminNS {
return this._server.xrpc.method(nsid, cfg)
}
resolveModerationReports<AV extends AuthVerifier>(
queryModerationEvents<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
ComAtprotoAdminResolveModerationReports.Handler<ExtractAuth<AV>>,
ComAtprotoAdminResolveModerationReports.HandlerReqCtx<ExtractAuth<AV>>
ComAtprotoAdminQueryModerationEvents.Handler<ExtractAuth<AV>>,
ComAtprotoAdminQueryModerationEvents.HandlerReqCtx<ExtractAuth<AV>>
>,
) {
const nsid = 'com.atproto.admin.resolveModerationReports' // @ts-ignore
const nsid = 'com.atproto.admin.queryModerationEvents' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
reverseModerationAction<AV extends AuthVerifier>(
queryModerationStatuses<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
ComAtprotoAdminReverseModerationAction.Handler<ExtractAuth<AV>>,
ComAtprotoAdminReverseModerationAction.HandlerReqCtx<ExtractAuth<AV>>
ComAtprotoAdminQueryModerationStatuses.Handler<ExtractAuth<AV>>,
ComAtprotoAdminQueryModerationStatuses.HandlerReqCtx<ExtractAuth<AV>>
>,
) {
const nsid = 'com.atproto.admin.reverseModerationAction' // @ts-ignore
const nsid = 'com.atproto.admin.queryModerationStatuses' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
@ -374,17 +348,6 @@ export class AdminNS {
return this._server.xrpc.method(nsid, cfg)
}
takeModerationAction<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
ComAtprotoAdminTakeModerationAction.Handler<ExtractAuth<AV>>,
ComAtprotoAdminTakeModerationAction.HandlerReqCtx<ExtractAuth<AV>>
>,
) {
const nsid = 'com.atproto.admin.takeModerationAction' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
updateAccountEmail<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,

File diff suppressed because it is too large Load Diff

@ -28,43 +28,55 @@ export function validateStatusAttr(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#statusAttr', v)
}
export interface ActionView {
export interface ModEventView {
id: number
action: ActionType
/** Indicates how long this action is meant to be in effect before automatically expiring. */
durationInHours?: number
event:
| ModEventTakedown
| ModEventReverseTakedown
| ModEventComment
| ModEventReport
| ModEventLabel
| ModEventAcknowledge
| ModEventEscalate
| ModEventMute
| ModEventEmail
| { $type: string; [k: string]: unknown }
subject:
| RepoRef
| ComAtprotoRepoStrongRef.Main
| { $type: string; [k: string]: unknown }
subjectBlobCids: string[]
createLabelVals?: string[]
negateLabelVals?: string[]
reason: string
createdBy: string
createdAt: string
reversal?: ActionReversal
resolvedReportIds: number[]
creatorHandle?: string
subjectHandle?: string
[k: string]: unknown
}
export function isActionView(v: unknown): v is ActionView {
export function isModEventView(v: unknown): v is ModEventView {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#actionView'
v.$type === 'com.atproto.admin.defs#modEventView'
)
}
export function validateActionView(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#actionView', v)
export function validateModEventView(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventView', v)
}
export interface ActionViewDetail {
export interface ModEventViewDetail {
id: number
action: ActionType
/** Indicates how long this action is meant to be in effect before automatically expiring. */
durationInHours?: number
event:
| ModEventTakedown
| ModEventReverseTakedown
| ModEventComment
| ModEventReport
| ModEventLabel
| ModEventAcknowledge
| ModEventEscalate
| ModEventMute
| { $type: string; [k: string]: unknown }
subject:
| RepoView
| RepoViewNotFound
@ -72,87 +84,27 @@ export interface ActionViewDetail {
| RecordViewNotFound
| { $type: string; [k: string]: unknown }
subjectBlobs: BlobView[]
createLabelVals?: string[]
negateLabelVals?: string[]
reason: string
createdBy: string
createdAt: string
reversal?: ActionReversal
resolvedReports: ReportView[]
[k: string]: unknown
}
export function isActionViewDetail(v: unknown): v is ActionViewDetail {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#actionViewDetail'
)
}
export function validateActionViewDetail(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#actionViewDetail', v)
}
export interface ActionViewCurrent {
id: number
action: ActionType
/** Indicates how long this action is meant to be in effect before automatically expiring. */
durationInHours?: number
[k: string]: unknown
}
export function isActionViewCurrent(v: unknown): v is ActionViewCurrent {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#actionViewCurrent'
)
}
export function validateActionViewCurrent(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#actionViewCurrent', v)
}
export interface ActionReversal {
reason: string
createdBy: string
createdAt: string
[k: string]: unknown
}
export function isActionReversal(v: unknown): v is ActionReversal {
export function isModEventViewDetail(v: unknown): v is ModEventViewDetail {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#actionReversal'
v.$type === 'com.atproto.admin.defs#modEventViewDetail'
)
}
export function validateActionReversal(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#actionReversal', v)
export function validateModEventViewDetail(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventViewDetail', v)
}
export type ActionType =
| 'lex:com.atproto.admin.defs#takedown'
| 'lex:com.atproto.admin.defs#flag'
| 'lex:com.atproto.admin.defs#acknowledge'
| 'lex:com.atproto.admin.defs#escalate'
| (string & {})
/** Moderation action type: Takedown. Indicates that content should not be served by the PDS. */
export const TAKEDOWN = 'com.atproto.admin.defs#takedown'
/** Moderation action type: Flag. Indicates that the content was reviewed and considered to violate PDS rules, but may still be served. */
export const FLAG = 'com.atproto.admin.defs#flag'
/** Moderation action type: Acknowledge. Indicates that the content was reviewed and not considered to violate PDS rules. */
export const ACKNOWLEDGE = 'com.atproto.admin.defs#acknowledge'
/** Moderation action type: Escalate. Indicates that the content has been flagged for additional review. */
export const ESCALATE = 'com.atproto.admin.defs#escalate'
export interface ReportView {
id: number
reasonType: ComAtprotoModerationDefs.ReasonType
reason?: string
comment?: string
subjectRepoHandle?: string
subject:
| RepoRef
@ -176,19 +128,56 @@ export function validateReportView(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#reportView', v)
}
export interface SubjectStatusView {
id: number
subject:
| RepoRef
| ComAtprotoRepoStrongRef.Main
| { $type: string; [k: string]: unknown }
subjectBlobCids?: string[]
subjectRepoHandle?: string
/** Timestamp referencing when the last update was made to the moderation status of the subject */
updatedAt: string
/** Timestamp referencing the first moderation status impacting event was emitted on the subject */
createdAt: string
reviewState: SubjectReviewState
/** Sticky comment on the subject. */
comment?: string
muteUntil?: string
lastReviewedBy?: string
lastReviewedAt?: string
lastReportedAt?: string
takendown?: boolean
suspendUntil?: string
[k: string]: unknown
}
export function isSubjectStatusView(v: unknown): v is SubjectStatusView {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#subjectStatusView'
)
}
export function validateSubjectStatusView(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#subjectStatusView', v)
}
export interface ReportViewDetail {
id: number
reasonType: ComAtprotoModerationDefs.ReasonType
reason?: string
comment?: string
subject:
| RepoView
| RepoViewNotFound
| RecordView
| RecordViewNotFound
| { $type: string; [k: string]: unknown }
subjectStatus?: SubjectStatusView
reportedBy: string
createdAt: string
resolvedByActions: ActionView[]
resolvedByActions: ModEventView[]
[k: string]: unknown
}
@ -400,7 +389,7 @@ export function validateRecordViewNotFound(v: unknown): ValidationResult {
}
export interface Moderation {
currentAction?: ActionViewCurrent
subjectStatus?: SubjectStatusView
[k: string]: unknown
}
@ -417,9 +406,7 @@ export function validateModeration(v: unknown): ValidationResult {
}
export interface ModerationDetail {
currentAction?: ActionViewCurrent
actions: ActionView[]
reports: ReportView[]
subjectStatus?: SubjectStatusView
[k: string]: unknown
}
@ -496,3 +483,208 @@ export function isVideoDetails(v: unknown): v is VideoDetails {
export function validateVideoDetails(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#videoDetails', v)
}
export type SubjectReviewState =
| 'lex:com.atproto.admin.defs#reviewOpen'
| 'lex:com.atproto.admin.defs#reviewEscalated'
| 'lex:com.atproto.admin.defs#reviewClosed'
| (string & {})
/** Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator */
export const REVIEWOPEN = 'com.atproto.admin.defs#reviewOpen'
/** Moderator review status of a subject: Escalated. Indicates that the subject was escalated for review by a moderator */
export const REVIEWESCALATED = 'com.atproto.admin.defs#reviewEscalated'
/** Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator */
export const REVIEWCLOSED = 'com.atproto.admin.defs#reviewClosed'
/** Take down a subject permanently or temporarily */
export interface ModEventTakedown {
comment?: string
/** Indicates how long the takedown should be in effect before automatically expiring. */
durationInHours?: number
[k: string]: unknown
}
export function isModEventTakedown(v: unknown): v is ModEventTakedown {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventTakedown'
)
}
export function validateModEventTakedown(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventTakedown', v)
}
/** Revert take down action on a subject */
export interface ModEventReverseTakedown {
/** Describe reasoning behind the reversal. */
comment?: string
[k: string]: unknown
}
export function isModEventReverseTakedown(
v: unknown,
): v is ModEventReverseTakedown {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventReverseTakedown'
)
}
export function validateModEventReverseTakedown(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventReverseTakedown', v)
}
/** Add a comment to a subject */
export interface ModEventComment {
comment: string
/** Make the comment persistent on the subject */
sticky?: boolean
[k: string]: unknown
}
export function isModEventComment(v: unknown): v is ModEventComment {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventComment'
)
}
export function validateModEventComment(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventComment', v)
}
/** Report a subject */
export interface ModEventReport {
comment?: string
reportType: ComAtprotoModerationDefs.ReasonType
[k: string]: unknown
}
export function isModEventReport(v: unknown): v is ModEventReport {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventReport'
)
}
export function validateModEventReport(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventReport', v)
}
/** Apply/Negate labels on a subject */
export interface ModEventLabel {
comment?: string
createLabelVals: string[]
negateLabelVals: string[]
[k: string]: unknown
}
export function isModEventLabel(v: unknown): v is ModEventLabel {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventLabel'
)
}
export function validateModEventLabel(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventLabel', v)
}
export interface ModEventAcknowledge {
comment?: string
[k: string]: unknown
}
export function isModEventAcknowledge(v: unknown): v is ModEventAcknowledge {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventAcknowledge'
)
}
export function validateModEventAcknowledge(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventAcknowledge', v)
}
export interface ModEventEscalate {
comment?: string
[k: string]: unknown
}
export function isModEventEscalate(v: unknown): v is ModEventEscalate {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventEscalate'
)
}
export function validateModEventEscalate(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventEscalate', v)
}
/** Mute incoming reports on a subject */
export interface ModEventMute {
comment?: string
/** Indicates how long the subject should remain muted. */
durationInHours: number
[k: string]: unknown
}
export function isModEventMute(v: unknown): v is ModEventMute {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventMute'
)
}
export function validateModEventMute(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventMute', v)
}
/** Unmute action on a subject */
export interface ModEventUnmute {
/** Describe reasoning behind the reversal. */
comment?: string
[k: string]: unknown
}
export function isModEventUnmute(v: unknown): v is ModEventUnmute {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventUnmute'
)
}
export function validateModEventUnmute(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventUnmute', v)
}
/** Keep a log of outgoing email to a user */
export interface ModEventEmail {
/** The subject line of the email sent to the user. */
subjectLine: string
[k: string]: unknown
}
export function isModEventEmail(v: unknown): v is ModEventEmail {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'com.atproto.admin.defs#modEventEmail'
)
}
export function validateModEventEmail(v: unknown): ValidationResult {
return lexicons.validate('com.atproto.admin.defs#modEventEmail', v)
}

@ -13,26 +13,28 @@ import * as ComAtprotoRepoStrongRef from '../repo/strongRef'
export interface QueryParams {}
export interface InputSchema {
action:
| 'com.atproto.admin.defs#takedown'
| 'com.atproto.admin.defs#flag'
| 'com.atproto.admin.defs#acknowledge'
| (string & {})
event:
| ComAtprotoAdminDefs.ModEventTakedown
| ComAtprotoAdminDefs.ModEventAcknowledge
| ComAtprotoAdminDefs.ModEventEscalate
| ComAtprotoAdminDefs.ModEventComment
| ComAtprotoAdminDefs.ModEventLabel
| ComAtprotoAdminDefs.ModEventReport
| ComAtprotoAdminDefs.ModEventMute
| ComAtprotoAdminDefs.ModEventReverseTakedown
| ComAtprotoAdminDefs.ModEventUnmute
| ComAtprotoAdminDefs.ModEventEmail
| { $type: string; [k: string]: unknown }
subject:
| ComAtprotoAdminDefs.RepoRef
| ComAtprotoRepoStrongRef.Main
| { $type: string; [k: string]: unknown }
subjectBlobCids?: string[]
createLabelVals?: string[]
negateLabelVals?: string[]
reason: string
/** Indicates how long this action is meant to be in effect before automatically expiring. */
durationInHours?: number
createdBy: string
[k: string]: unknown
}
export type OutputSchema = ComAtprotoAdminDefs.ActionView
export type OutputSchema = ComAtprotoAdminDefs.ModEventView
export interface HandlerInput {
encoding: 'application/json'

@ -14,7 +14,7 @@ export interface QueryParams {
}
export type InputSchema = undefined
export type OutputSchema = ComAtprotoAdminDefs.ActionViewDetail
export type OutputSchema = ComAtprotoAdminDefs.ModEventViewDetail
export type HandlerInput = undefined
export interface HandlerSuccess {

@ -1,41 +0,0 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import express from 'express'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { lexicons } from '../../../../lexicons'
import { isObj, hasProp } from '../../../../util'
import { CID } from 'multiformats/cid'
import { HandlerAuth } from '@atproto/xrpc-server'
import * as ComAtprotoAdminDefs from './defs'
export interface QueryParams {
id: number
}
export type InputSchema = undefined
export type OutputSchema = ComAtprotoAdminDefs.ReportViewDetail
export type HandlerInput = undefined
export interface HandlerSuccess {
encoding: 'application/json'
body: OutputSchema
headers?: { [key: string]: string }
}
export interface HandlerError {
status: number
message?: string
}
export type HandlerOutput = HandlerError | HandlerSuccess
export type HandlerReqCtx<HA extends HandlerAuth = never> = {
auth: HA
params: QueryParams
input: HandlerInput
req: express.Request
res: express.Response
}
export type Handler<HA extends HandlerAuth = never> = (
ctx: HandlerReqCtx<HA>,
) => Promise<HandlerOutput> | HandlerOutput

@ -10,7 +10,14 @@ import { HandlerAuth } from '@atproto/xrpc-server'
import * as ComAtprotoAdminDefs from './defs'
export interface QueryParams {
/** The types of events (fully qualified string in the format of com.atproto.admin#modEvent<name>) to filter by. If not specified, all events are returned. */
types?: string[]
createdBy?: string
/** Sort direction for the events. Defaults to descending order of created at timestamp. */
sortDirection: 'asc' | 'desc'
subject?: string
/** If true, events on all record types (posts, lists, profile etc.) owned by the did are returned */
includeAllUserRecords: boolean
limit: number
cursor?: string
}
@ -19,7 +26,7 @@ export type InputSchema = undefined
export interface OutputSchema {
cursor?: string
actions: ComAtprotoAdminDefs.ActionView[]
events: ComAtprotoAdminDefs.ModEventView[]
[k: string]: unknown
}

@ -11,29 +11,36 @@ import * as ComAtprotoAdminDefs from './defs'
export interface QueryParams {
subject?: string
/** Search subjects by keyword from comments */
comment?: string
/** Search subjects reported after a given timestamp */
reportedAfter?: string
/** Search subjects reported before a given timestamp */
reportedBefore?: string
/** Search subjects reviewed after a given timestamp */
reviewedAfter?: string
/** Search subjects reviewed before a given timestamp */
reviewedBefore?: string
/** By default, we don't include muted subjects in the results. Set this to true to include them. */
includeMuted?: boolean
/** Specify when fetching subjects in a certain state */
reviewState?: string
ignoreSubjects?: string[]
/** Get all reports that were actioned by a specific moderator. */
actionedBy?: string
/** Filter reports made by one or more DIDs. */
reporters?: string[]
resolved?: boolean
actionType?:
| 'com.atproto.admin.defs#takedown'
| 'com.atproto.admin.defs#flag'
| 'com.atproto.admin.defs#acknowledge'
| 'com.atproto.admin.defs#escalate'
| (string & {})
/** Get all subject statuses that were reviewed by a specific moderator */
lastReviewedBy?: string
sortField: 'lastReviewedAt' | 'lastReportedAt'
sortDirection: 'asc' | 'desc'
/** Get subjects that were taken down */
takendown?: boolean
limit: number
cursor?: string
/** Reverse the order of the returned records. When true, returns reports in chronological order. */
reverse?: boolean
}
export type InputSchema = undefined
export interface OutputSchema {
cursor?: string
reports: ComAtprotoAdminDefs.ReportView[]
subjectStatuses: ComAtprotoAdminDefs.SubjectStatusView[]
[k: string]: unknown
}

@ -1,49 +0,0 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import express from 'express'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { lexicons } from '../../../../lexicons'
import { isObj, hasProp } from '../../../../util'
import { CID } from 'multiformats/cid'
import { HandlerAuth } from '@atproto/xrpc-server'
import * as ComAtprotoAdminDefs from './defs'
export interface QueryParams {}
export interface InputSchema {
actionId: number
reportIds: number[]
createdBy: string
[k: string]: unknown
}
export type OutputSchema = ComAtprotoAdminDefs.ActionView
export interface HandlerInput {
encoding: 'application/json'
body: InputSchema
}
export interface HandlerSuccess {
encoding: 'application/json'
body: OutputSchema
headers?: { [key: string]: string }
}
export interface HandlerError {
status: number
message?: string
}
export type HandlerOutput = HandlerError | HandlerSuccess
export type HandlerReqCtx<HA extends HandlerAuth = never> = {
auth: HA
params: QueryParams
input: HandlerInput
req: express.Request
res: express.Response
}
export type Handler<HA extends HandlerAuth = never> = (
ctx: HandlerReqCtx<HA>,
) => Promise<HandlerOutput> | HandlerOutput

@ -1,49 +0,0 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import express from 'express'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { lexicons } from '../../../../lexicons'
import { isObj, hasProp } from '../../../../util'
import { CID } from 'multiformats/cid'
import { HandlerAuth } from '@atproto/xrpc-server'
import * as ComAtprotoAdminDefs from './defs'
export interface QueryParams {}
export interface InputSchema {
id: number
reason: string
createdBy: string
[k: string]: unknown
}
export type OutputSchema = ComAtprotoAdminDefs.ActionView
export interface HandlerInput {
encoding: 'application/json'
body: InputSchema
}
export interface HandlerSuccess {
encoding: 'application/json'
body: OutputSchema
headers?: { [key: string]: string }
}
export interface HandlerError {
status: number
message?: string
}
export type HandlerOutput = HandlerError | HandlerSuccess
export type HandlerReqCtx<HA extends HandlerAuth = never> = {
auth: HA
params: QueryParams
input: HandlerInput
req: express.Request
res: express.Response
}
export type Handler<HA extends HandlerAuth = never> = (
ctx: HandlerReqCtx<HA>,
) => Promise<HandlerOutput> | HandlerOutput

@ -14,6 +14,7 @@ export interface InputSchema {
recipientDid: string
content: string
subject?: string
senderDid: string
[k: string]: unknown
}

@ -0,0 +1,414 @@
import { sql } from 'kysely'
import { DatabaseCoordinator, PrimaryDatabase } from './index'
import { adjustModerationSubjectStatus } from './services/moderation/status'
import { ModerationEventRow } from './services/moderation/types'
type ModerationActionRow = Omit<ModerationEventRow, 'comment' | 'meta'> & {
reason: string | null
}
const getEnv = () => ({
DB_URL:
process.env.MODERATION_MIGRATION_DB_URL ||
'postgresql://pg:password@127.0.0.1:5433/postgres',
DB_POOL_SIZE: Number(process.env.MODERATION_MIGRATION_DB_POOL_SIZE) || 10,
DB_SCHEMA: process.env.MODERATION_MIGRATION_DB_SCHEMA || 'bsky',
})
const countEntries = async (db: PrimaryDatabase) => {
const [allActions, allReports] = await Promise.all([
db.db
// @ts-ignore
.selectFrom('moderation_action')
// @ts-ignore
.select((eb) => eb.fn.count<number>('id').as('count'))
.executeTakeFirstOrThrow(),
db.db
// @ts-ignore
.selectFrom('moderation_report')
// @ts-ignore
.select((eb) => eb.fn.count<number>('id').as('count'))
.executeTakeFirstOrThrow(),
])
return { reportsCount: allReports.count, actionsCount: allActions.count }
}
const countEvents = async (db: PrimaryDatabase) => {
const events = await db.db
.selectFrom('moderation_event')
.select((eb) => eb.fn.count<number>('id').as('count'))
.executeTakeFirstOrThrow()
return events.count
}
const getLatestReportLegacyRefId = async (db: PrimaryDatabase) => {
const events = await db.db
.selectFrom('moderation_event')
.select((eb) => eb.fn.max('legacyRefId').as('latestLegacyRefId'))
.where('action', '=', 'com.atproto.admin.defs#modEventReport')
.executeTakeFirstOrThrow()
return events.latestLegacyRefId
}
const countStatuses = async (db: PrimaryDatabase) => {
const events = await db.db
.selectFrom('moderation_subject_status')
.select((eb) => eb.fn.count<number>('id').as('count'))
.executeTakeFirstOrThrow()
return events.count
}
const processLegacyReports = async (
db: PrimaryDatabase,
legacyIds: number[],
) => {
if (!legacyIds.length) {
console.log('No legacy reports to process')
return
}
const reports = await db.db
.selectFrom('moderation_event')
.where('action', '=', 'com.atproto.admin.defs#modEventReport')
.where('legacyRefId', 'in', legacyIds)
.orderBy('legacyRefId', 'asc')
.selectAll()
.execute()
console.log(`Processing ${reports.length} reports from ${legacyIds.length}`)
await db.transaction(async (tx) => {
// This will be slow but we need to run this in sequence
for (const report of reports) {
await adjustModerationSubjectStatus(tx, report)
}
})
console.log(`Completed processing ${reports.length} reports`)
}
const getReportEventsAboveLegacyId = async (
db: PrimaryDatabase,
aboveLegacyId: number,
) => {
return await db.db
.selectFrom('moderation_event')
.where('action', '=', 'com.atproto.admin.defs#modEventReport')
.where('legacyRefId', '>', aboveLegacyId)
.select(sql<number>`"legacyRefId"`.as('legacyRefId'))
.execute()
}
const createEvents = async (
db: PrimaryDatabase,
opts?: { onlyReportsAboveId: number },
) => {
const commonColumnsToSelect = [
'subjectDid',
'subjectUri',
'subjectType',
'subjectCid',
sql`reason`.as('comment'),
'createdAt',
]
const commonColumnsToInsert = [
'subjectDid',
'subjectUri',
'subjectType',
'subjectCid',
'comment',
'createdAt',
'action',
'createdBy',
] as const
let totalActions: number
if (!opts?.onlyReportsAboveId) {
await db.db
.insertInto('moderation_event')
.columns([
'id',
...commonColumnsToInsert,
'createLabelVals',
'negateLabelVals',
'durationInHours',
'expiresAt',
])
.expression((eb) =>
eb
// @ts-ignore
.selectFrom('moderation_action')
// @ts-ignore
.select([
'id',
...commonColumnsToSelect,
sql`CONCAT('com.atproto.admin.defs#modEvent', UPPER(SUBSTRING(SPLIT_PART(action, '#', 2) FROM 1 FOR 1)), SUBSTRING(SPLIT_PART(action, '#', 2) FROM 2))`.as(
'action',
),
'createdBy',
'createLabelVals',
'negateLabelVals',
'durationInHours',
'expiresAt',
])
.orderBy('id', 'asc'),
)
.execute()
totalActions = await countEvents(db)
console.log(`Created ${totalActions} events from actions`)
await sql`SELECT setval(pg_get_serial_sequence('moderation_event', 'id'), (select max(id) from moderation_event))`.execute(
db.db,
)
console.log('Reset the id sequence for moderation_event')
} else {
totalActions = await countEvents(db)
}
await db.db
.insertInto('moderation_event')
.columns([...commonColumnsToInsert, 'meta', 'legacyRefId'])
.expression((eb) => {
const builder = eb
// @ts-ignore
.selectFrom('moderation_report')
// @ts-ignore
.select([
...commonColumnsToSelect,
sql`'com.atproto.admin.defs#modEventReport'`.as('action'),
sql`"reportedByDid"`.as('createdBy'),
sql`json_build_object('reportType', "reasonType")`.as('meta'),
sql`id`.as('legacyRefId'),
])
if (opts?.onlyReportsAboveId) {
// @ts-ignore
return builder.where('id', '>', opts.onlyReportsAboveId)
}
return builder
})
.execute()
const totalEvents = await countEvents(db)
console.log(`Created ${totalEvents - totalActions} events from reports`)
return
}
const setReportedAtTimestamp = async (db: PrimaryDatabase) => {
console.log('Initiating lastReportedAt timestamp sync')
const didUpdate = await sql`
UPDATE moderation_subject_status
SET "lastReportedAt" = reports."createdAt"
FROM (
select "subjectDid", "subjectUri", MAX("createdAt") as "createdAt"
from moderation_report
where "subjectUri" is null
group by "subjectDid", "subjectUri"
) as reports
WHERE reports."subjectDid" = moderation_subject_status."did"
AND "recordPath" = ''
AND ("lastReportedAt" is null OR "lastReportedAt" < reports."createdAt")
`.execute(db.db)
console.log(
`Updated lastReportedAt for ${didUpdate.numUpdatedOrDeletedRows} did subject`,
)
const contentUpdate = await sql`
UPDATE moderation_subject_status
SET "lastReportedAt" = reports."createdAt"
FROM (
select "subjectDid", "subjectUri", MAX("createdAt") as "createdAt"
from moderation_report
where "subjectUri" is not null
group by "subjectDid", "subjectUri"
) as reports
WHERE reports."subjectDid" = moderation_subject_status."did"
AND "recordPath" is not null
AND POSITION(moderation_subject_status."recordPath" IN reports."subjectUri") > 0
AND ("lastReportedAt" is null OR "lastReportedAt" < reports."createdAt")
`.execute(db.db)
console.log(
`Updated lastReportedAt for ${contentUpdate.numUpdatedOrDeletedRows} subject with uri`,
)
}
const createStatusFromActions = async (db: PrimaryDatabase) => {
const allEvents = await db.db
// @ts-ignore
.selectFrom('moderation_action')
// @ts-ignore
.where('reversedAt', 'is', null)
// @ts-ignore
.select((eb) => eb.fn.count<number>('id').as('count'))
.executeTakeFirstOrThrow()
const chunkSize = 2500
const totalChunks = Math.ceil(allEvents.count / chunkSize)
console.log(`Processing ${allEvents.count} actions in ${totalChunks} chunks`)
await db.transaction(async (tx) => {
// This is not used for pagination but only for logging purposes
let currentChunk = 1
let lastProcessedId: undefined | number = 0
do {
const eventsQuery = tx.db
// @ts-ignore
.selectFrom('moderation_action')
// @ts-ignore
.where('reversedAt', 'is', null)
// @ts-ignore
.where('id', '>', lastProcessedId)
.limit(chunkSize)
.selectAll()
const events = (await eventsQuery.execute()) as ModerationActionRow[]
for (const event of events) {
// Remap action to event data type
const actionParts = event.action.split('#')
await adjustModerationSubjectStatus(tx, {
...event,
action: `com.atproto.admin.defs#modEvent${actionParts[1]
.charAt(0)
.toUpperCase()}${actionParts[1].slice(
1,
)}` as ModerationEventRow['action'],
comment: event.reason,
meta: null,
})
}
console.log(`Processed events chunk ${currentChunk} of ${totalChunks}`)
lastProcessedId = events.at(-1)?.id
currentChunk++
} while (lastProcessedId !== undefined)
})
console.log(`Events migration complete!`)
const totalStatuses = await countStatuses(db)
console.log(`Created ${totalStatuses} statuses`)
}
const remapFlagToAcknlowedge = async (db: PrimaryDatabase) => {
console.log('Initiating flag to ack remap')
const results = await sql`
UPDATE moderation_event
SET "action" = 'com.atproto.admin.defs#modEventAcknowledge'
WHERE action = 'com.atproto.admin.defs#modEventFlag'
`.execute(db.db)
console.log(`Remapped ${results.numUpdatedOrDeletedRows} flag actions to ack`)
}
const syncBlobCids = async (db: PrimaryDatabase) => {
console.log('Initiating blob cid sync')
const results = await sql`
UPDATE moderation_subject_status
SET "blobCids" = blob_action."cids"
FROM (
SELECT moderation_action."subjectUri", moderation_action."subjectDid", jsonb_agg(moderation_action_subject_blob."cid") as cids
FROM moderation_action_subject_blob
JOIN moderation_action
ON moderation_action.id = moderation_action_subject_blob."actionId"
WHERE moderation_action."reversedAt" is NULL
GROUP by moderation_action."subjectUri", moderation_action."subjectDid"
) as blob_action
WHERE did = "subjectDid" AND position("recordPath" IN "subjectUri") > 0
`.execute(db.db)
console.log(`Updated blob cids on ${results.numUpdatedOrDeletedRows} rows`)
}
async function updateStatusFromUnresolvedReports(db: PrimaryDatabase) {
const { ref } = db.db.dynamic
const reports = await db.db
// @ts-ignore
.selectFrom('moderation_report')
.whereNotExists((qb) =>
qb
.selectFrom('moderation_report_resolution')
.selectAll()
// @ts-ignore
.whereRef('reportId', '=', ref('moderation_report.id')),
)
.select(sql<number>`moderation_report.id`.as('legacyId'))
.execute()
console.log('Updating statuses based on unresolved reports')
await processLegacyReports(
db,
reports.map((report) => report.legacyId),
)
console.log('Completed updating statuses based on unresolved reports')
}
export async function MigrateModerationData() {
const env = getEnv()
const db = new DatabaseCoordinator({
schema: env.DB_SCHEMA,
primary: {
url: env.DB_URL,
poolSize: env.DB_POOL_SIZE,
},
replicas: [],
})
const primaryDb = db.getPrimary()
const [counts, existingEventsCount] = await Promise.all([
countEntries(primaryDb),
countEvents(primaryDb),
])
// If there are existing events in the moderation_event table, we assume that the migration has already been run
// so we just bring over any new reports since last run
if (existingEventsCount) {
console.log(
`Found ${existingEventsCount} existing events. Migrating ${counts.reportsCount} reports only, ignoring actions`,
)
const reportMigrationStartedAt = Date.now()
const latestReportLegacyRefId = await getLatestReportLegacyRefId(primaryDb)
if (latestReportLegacyRefId) {
await createEvents(primaryDb, {
onlyReportsAboveId: latestReportLegacyRefId,
})
const newReportEvents = await getReportEventsAboveLegacyId(
primaryDb,
latestReportLegacyRefId,
)
await processLegacyReports(
primaryDb,
newReportEvents.map((evt) => evt.legacyRefId),
)
await setReportedAtTimestamp(primaryDb)
} else {
console.log('No reports have been migrated into events yet, bailing.')
}
console.log(
`Time spent: ${(Date.now() - reportMigrationStartedAt) / 1000} seconds`,
)
console.log('Migration complete!')
return
}
const totalEntries = counts.actionsCount + counts.reportsCount
console.log(`Migrating ${totalEntries} rows of actions and reports`)
const startedAt = Date.now()
await createEvents(primaryDb)
// Important to run this before creation statuses from actions to ensure that we are not attempting to map flag actions
await remapFlagToAcknlowedge(primaryDb)
await createStatusFromActions(primaryDb)
await updateStatusFromUnresolvedReports(primaryDb)
await setReportedAtTimestamp(primaryDb)
await syncBlobCids(primaryDb)
console.log(`Time spent: ${(Date.now() - startedAt) / 1000 / 60} minutes`)
console.log('Migration complete!')
}

@ -1,19 +1,37 @@
import { Selectable, sql } from 'kysely'
import { CID } from 'multiformats/cid'
import { AtUri } from '@atproto/syntax'
import { InvalidRequestError } from '@atproto/xrpc-server'
import { PrimaryDatabase } from '../../db'
import { ModerationAction, ModerationReport } from '../../db/tables/moderation'
import { ModerationViews } from './views'
import { ImageUriBuilder } from '../../image/uri'
import { Main as StrongRef } from '../../lexicon/types/com/atproto/repo/strongRef'
import { ImageInvalidator } from '../../image/invalidator'
import {
isModEventComment,
isModEventLabel,
isModEventMute,
isModEventReport,
isModEventTakedown,
isModEventEmail,
RepoRef,
RepoBlobRef,
TAKEDOWN,
} from '../../lexicon/types/com/atproto/admin/defs'
import { Main as StrongRef } from '../../lexicon/types/com/atproto/repo/strongRef'
import { addHoursToDate } from '../../util/date'
import {
adjustModerationSubjectStatus,
getStatusIdentifierFromSubject,
} from './status'
import {
ModEventType,
ModerationEventRow,
ModerationEventRowWithHandle,
ModerationSubjectStatusRow,
ReversibleModerationEvent,
SubjectInfo,
} from './types'
import { ModerationEvent } from '../../db/tables/moderation'
import { paginate } from '../../db/pagination'
import { StatusKeyset, TimeIdKeyset } from './pagination'
export class ModerationService {
constructor(
@ -32,350 +50,311 @@ export class ModerationService {
views = new ModerationViews(this.db)
async getAction(id: number): Promise<ModerationActionRow | undefined> {
async getEvent(id: number): Promise<ModerationEventRow | undefined> {
return await this.db.db
.selectFrom('moderation_action')
.selectFrom('moderation_event')
.selectAll()
.where('id', '=', id)
.executeTakeFirst()
}
async getActionOrThrow(id: number): Promise<ModerationActionRow> {
const action = await this.getAction(id)
if (!action) throw new InvalidRequestError('Action not found')
return action
async getEventOrThrow(id: number): Promise<ModerationEventRow> {
const event = await this.getEvent(id)
if (!event) throw new InvalidRequestError('Moderation event not found')
return event
}
async getActions(opts: {
async getEvents(opts: {
subject?: string
createdBy?: string
limit: number
cursor?: string
}): Promise<ModerationActionRow[]> {
const { subject, limit, cursor } = opts
let builder = this.db.db.selectFrom('moderation_action')
if (subject) {
builder = builder.where((qb) => {
return qb
.where('subjectDid', '=', subject)
.orWhere('subjectUri', '=', subject)
})
}
if (cursor) {
const cursorNumeric = parseInt(cursor, 10)
if (isNaN(cursorNumeric)) {
throw new InvalidRequestError('Malformed cursor')
}
builder = builder.where('id', '<', cursorNumeric)
}
return await builder
.selectAll()
.orderBy('id', 'desc')
.limit(limit)
.execute()
}
async getReport(id: number): Promise<ModerationReportRow | undefined> {
return await this.db.db
.selectFrom('moderation_report')
.selectAll()
.where('id', '=', id)
.executeTakeFirst()
}
async getReports(opts: {
subject?: string
resolved?: boolean
actionType?: string
limit: number
cursor?: string
ignoreSubjects?: string[]
reverse?: boolean
reporters?: string[]
actionedBy?: string
}): Promise<ModerationReportRowWithHandle[]> {
includeAllUserRecords: boolean
types: ModerationEvent['action'][]
sortDirection?: 'asc' | 'desc'
}): Promise<{ cursor?: string; events: ModerationEventRowWithHandle[] }> {
const {
subject,
resolved,
actionType,
createdBy,
limit,
cursor,
ignoreSubjects,
reverse = false,
reporters,
actionedBy,
includeAllUserRecords,
sortDirection = 'desc',
types,
} = opts
const { ref } = this.db.db.dynamic
let builder = this.db.db.selectFrom('moderation_report')
let builder = this.db.db
.selectFrom('moderation_event')
.leftJoin(
'actor as creatorActor',
'creatorActor.did',
'moderation_event.createdBy',
)
.leftJoin(
'actor as subjectActor',
'subjectActor.did',
'moderation_event.subjectDid',
)
if (subject) {
builder = builder.where((qb) => {
if (includeAllUserRecords) {
// If subject is an at-uri, we need to extract the DID from the at-uri
// otherwise, subject is probably a DID already
if (subject.startsWith('at://')) {
const uri = new AtUri(subject)
return qb.where('subjectDid', '=', uri.hostname)
}
return qb.where('subjectDid', '=', subject)
}
return qb
.where('subjectDid', '=', subject)
.where((subQb) =>
subQb
.where('subjectDid', '=', subject)
.where('subjectUri', 'is', null),
)
.orWhere('subjectUri', '=', subject)
})
}
if (ignoreSubjects?.length) {
const ignoreUris: string[] = []
const ignoreDids: string[] = []
ignoreSubjects.forEach((subject) => {
if (subject.startsWith('at://')) {
ignoreUris.push(subject)
} else if (subject.startsWith('did:')) {
ignoreDids.push(subject)
if (types.length) {
builder = builder.where((qb) => {
if (types.length === 1) {
return qb.where('action', '=', types[0])
}
return qb.where('action', 'in', types)
})
if (ignoreDids.length) {
builder = builder.where('subjectDid', 'not in', ignoreDids)
}
if (ignoreUris.length) {
builder = builder.where((qb) => {
// Without the null condition, postgres will ignore all reports where `subjectUri` is null
// which will make all the account reports be ignored as well
return qb
.where('subjectUri', 'not in', ignoreUris)
.orWhere('subjectUri', 'is', null)
})
}
}
if (createdBy) {
builder = builder.where('createdBy', '=', createdBy)
}
if (reporters?.length) {
builder = builder.where('reportedByDid', 'in', reporters)
}
const { ref } = this.db.db.dynamic
const keyset = new TimeIdKeyset(
ref(`moderation_event.createdAt`),
ref('moderation_event.id'),
)
const paginatedBuilder = paginate(builder, {
limit,
cursor,
keyset,
direction: sortDirection,
tryIndex: true,
})
if (resolved !== undefined) {
const resolutionsQuery = this.db.db
.selectFrom('moderation_report_resolution')
.selectAll()
.whereRef(
'moderation_report_resolution.reportId',
'=',
ref('moderation_report.id'),
)
builder = resolved
? builder.whereExists(resolutionsQuery)
: builder.whereNotExists(resolutionsQuery)
}
if (actionType !== undefined || actionedBy !== undefined) {
let resolutionActionsQuery = this.db.db
.selectFrom('moderation_report_resolution')
.innerJoin(
'moderation_action',
'moderation_action.id',
'moderation_report_resolution.actionId',
)
.whereRef(
'moderation_report_resolution.reportId',
'=',
ref('moderation_report.id'),
)
if (actionType) {
resolutionActionsQuery = resolutionActionsQuery
.where('moderation_action.action', '=', sql`${actionType}`)
.where('moderation_action.reversedAt', 'is', null)
}
if (actionedBy) {
resolutionActionsQuery = resolutionActionsQuery.where(
'moderation_action.createdBy',
'=',
actionedBy,
)
}
builder = builder.whereExists(resolutionActionsQuery.selectAll())
}
if (cursor) {
const cursorNumeric = parseInt(cursor, 10)
if (isNaN(cursorNumeric)) {
throw new InvalidRequestError('Malformed cursor')
}
builder = builder.where('id', reverse ? '>' : '<', cursorNumeric)
}
return await builder
.leftJoin('actor', 'actor.did', 'moderation_report.subjectDid')
.selectAll(['moderation_report', 'actor'])
.orderBy('id', reverse ? 'asc' : 'desc')
.limit(limit)
const result = await paginatedBuilder
.selectAll(['moderation_event'])
.select([
'subjectActor.handle as subjectHandle',
'creatorActor.handle as creatorHandle',
])
.execute()
return { cursor: keyset.packFromResult(result), events: result }
}
async getReportOrThrow(id: number): Promise<ModerationReportRow> {
const report = await this.getReport(id)
if (!report) throw new InvalidRequestError('Report not found')
return report
async getReport(id: number): Promise<ModerationEventRow | undefined> {
return await this.db.db
.selectFrom('moderation_event')
.where('action', '=', 'com.atproto.admin.defs#modEventReport')
.selectAll()
.where('id', '=', id)
.executeTakeFirst()
}
async getCurrentActions(
async getCurrentStatus(
subject: { did: string } | { uri: AtUri } | { cids: CID[] },
) {
const { ref } = this.db.db.dynamic
let builder = this.db.db
.selectFrom('moderation_action')
.selectAll()
.where('reversedAt', 'is', null)
let builder = this.db.db.selectFrom('moderation_subject_status').selectAll()
if ('did' in subject) {
builder = builder
.where('subjectType', '=', 'com.atproto.admin.defs#repoRef')
.where('subjectDid', '=', subject.did)
builder = builder.where('did', '=', subject.did)
} else if ('uri' in subject) {
builder = builder
.where('subjectType', '=', 'com.atproto.repo.strongRef')
.where('subjectUri', '=', subject.uri.toString())
} else {
const blobsForAction = this.db.db
.selectFrom('moderation_action_subject_blob')
.selectAll()
.whereRef('actionId', '=', ref('moderation_action.id'))
.where(
'cid',
'in',
subject.cids.map((cid) => cid.toString()),
)
builder = builder.whereExists(blobsForAction)
builder = builder.where('recordPath', '=', subject.uri.toString())
}
// TODO: Handle the cid status
return await builder.execute()
}
async logAction(info: {
action: ModerationActionRow['action']
subject: { did: string } | { uri: AtUri; cid: CID }
subjectBlobCids?: CID[]
reason: string
createLabelVals?: string[]
negateLabelVals?: string[]
createdBy: string
createdAt?: Date
durationInHours?: number
}): Promise<ModerationActionRow> {
this.db.assertTransaction()
const {
action,
createdBy,
reason,
subject,
subjectBlobCids,
durationInHours,
createdAt = new Date(),
} = info
const createLabelVals =
info.createLabelVals && info.createLabelVals.length > 0
? info.createLabelVals.join(' ')
: undefined
const negateLabelVals =
info.negateLabelVals && info.negateLabelVals.length > 0
? info.negateLabelVals.join(' ')
: undefined
// Resolve subject info
let subjectInfo: SubjectInfo
buildSubjectInfo(
subject: { did: string } | { uri: AtUri; cid: CID },
subjectBlobCids?: CID[],
): SubjectInfo {
if ('did' in subject) {
if (subjectBlobCids?.length) {
throw new InvalidRequestError('Blobs do not apply to repo subjects')
}
// Allowing dids that may not exist: may have been deleted but needs to remain actionable.
subjectInfo = {
return {
subjectType: 'com.atproto.admin.defs#repoRef',
subjectDid: subject.did,
subjectUri: null,
subjectCid: null,
}
if (subjectBlobCids?.length) {
throw new InvalidRequestError('Blobs do not apply to repo subjects')
}
} else {
// Allowing records/blobs that may not exist: may have been deleted but needs to remain actionable.
subjectInfo = {
subjectType: 'com.atproto.repo.strongRef',
subjectDid: subject.uri.host,
subjectUri: subject.uri.toString(),
subjectCid: subject.cid.toString(),
}
}
const subjectActions = await this.getCurrentActions(subject)
if (subjectActions.length) {
throw new InvalidRequestError(
`Subject already has an active action: #${subjectActions[0].id}`,
'SubjectHasAction',
)
// Allowing records/blobs that may not exist: may have been deleted but needs to remain actionable.
return {
subjectType: 'com.atproto.repo.strongRef',
subjectDid: subject.uri.host,
subjectUri: subject.uri.toString(),
subjectCid: subject.cid.toString(),
}
const actionResult = await this.db.db
.insertInto('moderation_action')
}
async logEvent(info: {
event: ModEventType
subject: { did: string } | { uri: AtUri; cid: CID }
subjectBlobCids?: CID[]
createdBy: string
createdAt?: Date
}): Promise<ModerationEventRow> {
this.db.assertTransaction()
const {
event,
createdBy,
subject,
subjectBlobCids,
createdAt = new Date(),
} = info
// Resolve subject info
const subjectInfo = this.buildSubjectInfo(subject, subjectBlobCids)
const createLabelVals =
isModEventLabel(event) && event.createLabelVals.length > 0
? event.createLabelVals.join(' ')
: undefined
const negateLabelVals =
isModEventLabel(event) && event.negateLabelVals.length > 0
? event.negateLabelVals.join(' ')
: undefined
const meta: Record<string, string | boolean> = {}
if (isModEventReport(event)) {
meta.reportType = event.reportType
}
if (isModEventComment(event) && event.sticky) {
meta.sticky = event.sticky
}
if (isModEventEmail(event)) {
meta.subjectLine = event.subjectLine
}
const modEvent = await this.db.db
.insertInto('moderation_event')
.values({
action,
reason,
comment: event.comment ? `${event.comment}` : null,
action: event.$type as ModerationEvent['action'],
createdAt: createdAt.toISOString(),
createdBy,
createLabelVals,
negateLabelVals,
durationInHours,
durationInHours: event.durationInHours
? Number(event.durationInHours)
: null,
meta,
expiresAt:
durationInHours !== undefined
? addHoursToDate(durationInHours, createdAt).toISOString()
(isModEventTakedown(event) || isModEventMute(event)) &&
event.durationInHours
? addHoursToDate(event.durationInHours, createdAt).toISOString()
: undefined,
...subjectInfo,
})
.returningAll()
.executeTakeFirstOrThrow()
if (subjectBlobCids?.length && !('did' in subject)) {
const blobActions = await this.getCurrentActions({
cids: subjectBlobCids,
})
if (blobActions.length) {
throw new InvalidRequestError(
`Blob already has an active action: #${blobActions[0].id}`,
'SubjectHasAction',
)
}
await adjustModerationSubjectStatus(this.db, modEvent, subjectBlobCids)
await this.db.db
.insertInto('moderation_action_subject_blob')
.values(
subjectBlobCids.map((cid) => ({
actionId: actionResult.id,
cid: cid.toString(),
})),
)
.execute()
}
return actionResult
return modEvent
}
async getActionsDueForReversal(): Promise<ModerationActionRow[]> {
const actionsDueForReversal = await this.db.db
.selectFrom('moderation_action')
.where('durationInHours', 'is not', null)
.where('expiresAt', '<', new Date().toISOString())
.where('reversedAt', 'is', null)
async getLastReversibleEventForSubject({
did,
muteUntil,
recordPath,
suspendUntil,
}: ModerationSubjectStatusRow) {
const isSuspended = suspendUntil && new Date(suspendUntil) < new Date()
const isMuted = muteUntil && new Date(muteUntil) < new Date()
// If the subject is neither suspended nor muted don't bother finding the last reversible event
// Ideally, this should never happen because the caller of this method should only call this
// after ensuring that the suspended or muted subjects are being reversed
if (!isSuspended && !isMuted) {
return null
}
let builder = this.db.db
.selectFrom('moderation_event')
.where('subjectDid', '=', did)
if (recordPath) {
builder = builder.where('subjectUri', 'like', `%${recordPath}%`)
}
// Means the subject was suspended and needs to be unsuspended
if (isSuspended) {
builder = builder
.where('action', '=', 'com.atproto.admin.defs#modEventTakedown')
.where('durationInHours', 'is not', null)
}
if (isMuted) {
builder = builder
.where('action', '=', 'com.atproto.admin.defs#modEventMute')
.where('durationInHours', 'is not', null)
}
return await builder
.orderBy('id', 'desc')
.selectAll()
.limit(1)
.executeTakeFirst()
}
async getSubjectsDueForReversal(): Promise<ModerationSubjectStatusRow[]> {
const subjectsDueForReversal = await this.db.db
.selectFrom('moderation_subject_status')
.where('suspendUntil', '<', new Date().toISOString())
.orWhere('muteUntil', '<', new Date().toISOString())
.selectAll()
.execute()
return actionsDueForReversal
return subjectsDueForReversal
}
async revertAction({
id,
async revertState({
createdBy,
createdAt,
reason,
}: ReversibleModerationAction): Promise<{
result: ModerationActionRow
comment,
action,
subject,
}: ReversibleModerationEvent): Promise<{
result: ModerationEventRow
restored?: TakedownSubjects
}> {
const isRevertingTakedown =
action === 'com.atproto.admin.defs#modEventTakedown'
this.db.assertTransaction()
const result = await this.logReverseAction({
id,
const result = await this.logEvent({
event: {
$type: isRevertingTakedown
? 'com.atproto.admin.defs#modEventReverseTakedown'
: 'com.atproto.admin.defs#modEventUnmute',
comment: comment ?? undefined,
},
createdAt,
createdBy,
reason,
subject,
})
let restored: TakedownSubjects | undefined
if (!isRevertingTakedown) {
return { result, restored }
}
if (
result.action === TAKEDOWN &&
result.subjectType === 'com.atproto.admin.defs#repoRef' &&
result.subjectDid
) {
@ -394,7 +373,6 @@ export class ModerationService {
}
if (
result.action === TAKEDOWN &&
result.subjectType === 'com.atproto.repo.strongRef' &&
result.subjectUri
) {
@ -403,11 +381,14 @@ export class ModerationService {
uri,
})
const did = uri.hostname
const actionBlobs = await this.db.db
.selectFrom('moderation_action_subject_blob')
.where('actionId', '=', id)
.select('cid')
.execute()
// TODO: MOD_EVENT This bit needs testing
const subjectStatus = await this.db.db
.selectFrom('moderation_subject_status')
.where('did', '=', uri.host)
.where('recordPath', '=', `${uri.collection}/${uri.rkey}`)
.select('blobCids')
.executeTakeFirst()
const blobCids = subjectStatus?.blobCids || []
restored = {
did,
subjects: [
@ -416,10 +397,10 @@ export class ModerationService {
uri: result.subjectUri,
cid: result.subjectCid ?? '',
},
...actionBlobs.map((row) => ({
...blobCids.map((cid) => ({
$type: 'com.atproto.admin.defs#repoBlobRef',
did,
cid: row.cid,
cid,
recordUri: result.subjectUri,
})),
],
@ -429,29 +410,6 @@ export class ModerationService {
return { result, restored }
}
async logReverseAction(
info: ReversibleModerationAction,
): Promise<ModerationActionRow> {
const { id, createdBy, reason, createdAt = new Date() } = info
const result = await this.db.db
.updateTable('moderation_action')
.where('id', '=', id)
.set({
reversedAt: createdAt.toISOString(),
reversedBy: createdBy,
reversedReason: reason,
})
.returningAll()
.executeTakeFirst()
if (!result) {
throw new InvalidRequestError('Moderation action not found')
}
return result
}
async takedownRepo(info: {
takedownId: number
did: string
@ -536,64 +494,13 @@ export class ModerationService {
.execute()
}
async resolveReports(info: {
reportIds: number[]
actionId: number
createdBy: string
createdAt?: Date
}): Promise<void> {
const { reportIds, actionId, createdBy, createdAt = new Date() } = info
const action = await this.getActionOrThrow(actionId)
if (!reportIds.length) return
const reports = await this.db.db
.selectFrom('moderation_report')
.where('id', 'in', reportIds)
.select(['id', 'subjectType', 'subjectDid', 'subjectUri'])
.execute()
reportIds.forEach((reportId) => {
const report = reports.find((r) => r.id === reportId)
if (!report) throw new InvalidRequestError('Report not found')
if (action.subjectDid !== report.subjectDid) {
// Report and action always must target repo or record from the same did
throw new InvalidRequestError(
`Report ${report.id} cannot be resolved by action`,
)
}
if (
action.subjectType === 'com.atproto.repo.strongRef' &&
report.subjectType === 'com.atproto.repo.strongRef' &&
report.subjectUri !== action.subjectUri
) {
// If report and action are both for a record, they must be for the same record
throw new InvalidRequestError(
`Report ${report.id} cannot be resolved by action`,
)
}
})
await this.db.db
.insertInto('moderation_report_resolution')
.values(
reportIds.map((reportId) => ({
reportId,
actionId,
createdAt: createdAt.toISOString(),
createdBy,
})),
)
.onConflict((oc) => oc.doNothing())
.execute()
}
async report(info: {
reasonType: ModerationReportRow['reasonType']
reasonType: NonNullable<ModerationEventRow['meta']>['reportType']
reason?: string
subject: { did: string } | { uri: AtUri; cid: CID }
reportedBy: string
createdAt?: Date
}): Promise<ModerationReportRow> {
}): Promise<ModerationEventRow> {
const {
reasonType,
reason,
@ -602,39 +509,144 @@ export class ModerationService {
subject,
} = info
// Resolve subject info
let subjectInfo: SubjectInfo
if ('did' in subject) {
// Allowing dids that may not exist: may not be known yet to appview but needs to remain reportable.
subjectInfo = {
subjectType: 'com.atproto.admin.defs#repoRef',
subjectDid: subject.did,
subjectUri: null,
subjectCid: null,
}
} else {
// Allowing records/blobs that may not exist: may not be known yet to appview but needs to remain reportable.
subjectInfo = {
subjectType: 'com.atproto.repo.strongRef',
subjectDid: subject.uri.host,
subjectUri: subject.uri.toString(),
subjectCid: subject.cid.toString(),
}
const event = await this.logEvent({
event: {
$type: 'com.atproto.admin.defs#modEventReport',
reportType: reasonType,
comment: reason,
},
createdBy: reportedBy,
subject,
createdAt,
})
return event
}
async getSubjectStatuses({
cursor,
limit = 50,
takendown,
reviewState,
reviewedAfter,
reviewedBefore,
reportedAfter,
reportedBefore,
includeMuted,
ignoreSubjects,
sortDirection,
lastReviewedBy,
sortField,
subject,
}: {
cursor?: string
limit?: number
takendown?: boolean
reviewedBefore?: string
reviewState?: ModerationSubjectStatusRow['reviewState']
reviewedAfter?: string
reportedAfter?: string
reportedBefore?: string
includeMuted?: boolean
subject?: string
ignoreSubjects?: string[]
sortDirection: 'asc' | 'desc'
lastReviewedBy?: string
sortField: 'lastReviewedAt' | 'lastReportedAt'
}) {
let builder = this.db.db
.selectFrom('moderation_subject_status')
.leftJoin('actor', 'actor.did', 'moderation_subject_status.did')
if (subject) {
const subjectInfo = getStatusIdentifierFromSubject(subject)
builder = builder
.where('moderation_subject_status.did', '=', subjectInfo.did)
.where((qb) =>
subjectInfo.recordPath
? qb.where('recordPath', '=', subjectInfo.recordPath)
: qb.where('recordPath', '=', ''),
)
}
const report = await this.db.db
.insertInto('moderation_report')
.values({
reasonType,
reason: reason || null,
createdAt: createdAt.toISOString(),
reportedByDid: reportedBy,
...subjectInfo,
})
.returningAll()
.executeTakeFirstOrThrow()
if (ignoreSubjects?.length) {
builder = builder
.where('moderation_subject_status.did', 'not in', ignoreSubjects)
.where('recordPath', 'not in', ignoreSubjects)
}
return report
if (reviewState) {
builder = builder.where('reviewState', '=', reviewState)
}
if (lastReviewedBy) {
builder = builder.where('lastReviewedBy', '=', lastReviewedBy)
}
if (reviewedAfter) {
builder = builder.where('lastReviewedAt', '>', reviewedAfter)
}
if (reviewedBefore) {
builder = builder.where('lastReviewedAt', '<', reviewedBefore)
}
if (reportedAfter) {
builder = builder.where('lastReviewedAt', '>', reportedAfter)
}
if (reportedBefore) {
builder = builder.where('lastReportedAt', '<', reportedBefore)
}
if (takendown) {
builder = builder.where('takendown', '=', true)
}
if (!includeMuted) {
builder = builder.where((qb) =>
qb
.where('muteUntil', '<', new Date().toISOString())
.orWhere('muteUntil', 'is', null),
)
}
const { ref } = this.db.db.dynamic
const keyset = new StatusKeyset(
ref(`moderation_subject_status.${sortField}`),
ref('moderation_subject_status.id'),
)
const paginatedBuilder = paginate(builder, {
limit,
cursor,
keyset,
direction: sortDirection,
tryIndex: true,
nullsLast: true,
})
const results = await paginatedBuilder
.select('actor.handle as handle')
.selectAll('moderation_subject_status')
.execute()
return { statuses: results, cursor: keyset.packFromResult(results) }
}
async isSubjectTakendown(
subject: { did: string } | { uri: AtUri },
): Promise<boolean> {
const { did, recordPath } = getStatusIdentifierFromSubject(
'did' in subject ? subject.did : subject.uri,
)
let builder = this.db.db
.selectFrom('moderation_subject_status')
.where('did', '=', did)
.where('recordPath', '=', recordPath || '')
const result = await builder.select('takendown').executeTakeFirst()
return !!result?.takendown
}
}
@ -642,30 +654,3 @@ export type TakedownSubjects = {
did: string
subjects: (RepoRef | RepoBlobRef | StrongRef)[]
}
export type ModerationActionRow = Selectable<ModerationAction>
export type ReversibleModerationAction = Pick<
ModerationActionRow,
'id' | 'createdBy' | 'reason'
> & {
createdAt?: Date
}
export type ModerationReportRow = Selectable<ModerationReport>
export type ModerationReportRowWithHandle = ModerationReportRow & {
handle?: string | null
}
export type SubjectInfo =
| {
subjectType: 'com.atproto.admin.defs#repoRef'
subjectDid: string
subjectUri: null
subjectCid: null
}
| {
subjectType: 'com.atproto.repo.strongRef'
subjectDid: string
subjectUri: string
subjectCid: string
}

@ -0,0 +1,96 @@
import { InvalidRequestError } from '@atproto/xrpc-server'
import { DynamicModule, sql } from 'kysely'
import { Cursor, GenericKeyset } from '../../db/pagination'
type StatusKeysetParam = {
lastReviewedAt: string | null
lastReportedAt: string | null
id: number
}
export class StatusKeyset extends GenericKeyset<StatusKeysetParam, Cursor> {
labelResult(result: StatusKeysetParam): Cursor
labelResult(result: StatusKeysetParam) {
const primaryField = (
this.primary as ReturnType<DynamicModule['ref']>
).dynamicReference.includes('lastReviewedAt')
? 'lastReviewedAt'
: 'lastReportedAt'
return {
primary: result[primaryField]
? new Date(`${result[primaryField]}`).getTime().toString()
: '',
secondary: result.id.toString(),
}
}
labeledResultToCursor(labeled: Cursor) {
return {
primary: labeled.primary,
secondary: labeled.secondary,
}
}
cursorToLabeledResult(cursor: Cursor) {
return {
primary: cursor.primary
? new Date(parseInt(cursor.primary, 10)).toISOString()
: '',
secondary: cursor.secondary,
}
}
unpackCursor(cursorStr?: string): Cursor | undefined {
if (!cursorStr) return
const result = cursorStr.split('::')
const [primary, secondary, ...others] = result
if (!secondary || others.length > 0) {
throw new InvalidRequestError('Malformed cursor')
}
return {
primary,
secondary,
}
}
// This is specifically built to handle nullable columns as primary sorting column
getSql(labeled?: Cursor, direction?: 'asc' | 'desc') {
if (labeled === undefined) return
if (direction === 'asc') {
return !labeled.primary
? sql`(${this.primary} IS NULL AND ${this.secondary} > ${labeled.secondary})`
: sql`((${this.primary}, ${this.secondary}) > (${labeled.primary}, ${labeled.secondary}) OR (${this.primary} is null))`
} else {
return !labeled.primary
? sql`(${this.primary} IS NULL AND ${this.secondary} < ${labeled.secondary})`
: sql`((${this.primary}, ${this.secondary}) < (${labeled.primary}, ${labeled.secondary}) OR (${this.primary} is null))`
}
}
}
type TimeIdKeysetParam = {
id: number
createdAt: string
}
type TimeIdResult = TimeIdKeysetParam
export class TimeIdKeyset extends GenericKeyset<TimeIdKeysetParam, Cursor> {
labelResult(result: TimeIdResult): Cursor
labelResult(result: TimeIdResult) {
return { primary: result.createdAt, secondary: result.id.toString() }
}
labeledResultToCursor(labeled: Cursor) {
return {
primary: new Date(labeled.primary).getTime().toString(),
secondary: labeled.secondary,
}
}
cursorToLabeledResult(cursor: Cursor) {
const primaryDate = new Date(parseInt(cursor.primary, 10))
if (isNaN(primaryDate.getTime())) {
throw new InvalidRequestError('Malformed cursor')
}
return {
primary: primaryDate.toISOString(),
secondary: cursor.secondary,
}
}
}

@ -0,0 +1,244 @@
// This may require better organization but for now, just dumping functions here containing DB queries for moderation status
import { AtUri } from '@atproto/syntax'
import { PrimaryDatabase } from '../../db'
import {
ModerationEvent,
ModerationSubjectStatus,
} from '../../db/tables/moderation'
import {
REVIEWOPEN,
REVIEWCLOSED,
REVIEWESCALATED,
} from '../../lexicon/types/com/atproto/admin/defs'
import { ModerationEventRow, ModerationSubjectStatusRow } from './types'
import { HOUR } from '@atproto/common'
import { CID } from 'multiformats/cid'
import { sql } from 'kysely'
const getSubjectStatusForModerationEvent = ({
action,
createdBy,
createdAt,
durationInHours,
}: {
action: string
createdBy: string
createdAt: string
durationInHours: number | null
}): Partial<ModerationSubjectStatusRow> | null => {
switch (action) {
case 'com.atproto.admin.defs#modEventAcknowledge':
return {
lastReviewedBy: createdBy,
reviewState: REVIEWCLOSED,
lastReviewedAt: createdAt,
}
case 'com.atproto.admin.defs#modEventReport':
return {
reviewState: REVIEWOPEN,
lastReportedAt: createdAt,
}
case 'com.atproto.admin.defs#modEventEscalate':
return {
lastReviewedBy: createdBy,
reviewState: REVIEWESCALATED,
lastReviewedAt: createdAt,
}
case 'com.atproto.admin.defs#modEventReverseTakedown':
return {
lastReviewedBy: createdBy,
reviewState: REVIEWCLOSED,
takendown: false,
suspendUntil: null,
lastReviewedAt: createdAt,
}
case 'com.atproto.admin.defs#modEventUnmute':
return {
lastReviewedBy: createdBy,
muteUntil: null,
reviewState: REVIEWOPEN,
lastReviewedAt: createdAt,
}
case 'com.atproto.admin.defs#modEventTakedown':
return {
takendown: true,
lastReviewedBy: createdBy,
reviewState: REVIEWCLOSED,
lastReviewedAt: createdAt,
suspendUntil: durationInHours
? new Date(Date.now() + durationInHours * HOUR).toISOString()
: null,
}
case 'com.atproto.admin.defs#modEventMute':
return {
lastReviewedBy: createdBy,
reviewState: REVIEWOPEN,
lastReviewedAt: createdAt,
// By default, mute for 24hrs
muteUntil: new Date(
Date.now() + (durationInHours || 24) * HOUR,
).toISOString(),
}
case 'com.atproto.admin.defs#modEventComment':
return {
lastReviewedBy: createdBy,
lastReviewedAt: createdAt,
}
default:
return null
}
}
// Based on a given moderation action event, this function will update the moderation status of the subject
// If there's no existing status, it will create one
// If the action event does not affect the status, it will do nothing
export const adjustModerationSubjectStatus = async (
db: PrimaryDatabase,
moderationEvent: ModerationEventRow,
blobCids?: CID[],
) => {
const {
action,
subjectDid,
subjectUri,
subjectCid,
createdBy,
meta,
comment,
createdAt,
} = moderationEvent
const subjectStatus = getSubjectStatusForModerationEvent({
action,
createdBy,
createdAt,
durationInHours: moderationEvent.durationInHours,
})
// If there are no subjectStatus that means there are no side-effect of the incoming event
if (!subjectStatus) {
return null
}
const now = new Date().toISOString()
// If subjectUri exists, it's not a repoRef so pass along the uri to get identifier back
const identifier = getStatusIdentifierFromSubject(subjectUri || subjectDid)
db.assertTransaction()
const currentStatus = await db.db
.selectFrom('moderation_subject_status')
.where('did', '=', identifier.did)
.where('recordPath', '=', identifier.recordPath)
.selectAll()
.executeTakeFirst()
if (
currentStatus?.reviewState === REVIEWESCALATED &&
subjectStatus.reviewState === REVIEWOPEN
) {
// If the current status is escalated and the incoming event is to open the review
// We want to keep the status as escalated
subjectStatus.reviewState = REVIEWESCALATED
}
// Set these because we don't want to override them if they're already set
const defaultData = {
comment: null,
// Defaulting reviewState to open for any event may not be the desired behavior.
// For instance, if a subject never had any event and we just want to leave a comment to keep an eye on it
// that shouldn't mean we want to review the subject
reviewState: REVIEWOPEN,
recordCid: subjectCid || null,
}
const newStatus = {
...defaultData,
...subjectStatus,
}
if (
action === 'com.atproto.admin.defs#modEventReverseTakedown' &&
!subjectStatus.takendown
) {
newStatus.takendown = false
subjectStatus.takendown = false
}
if (action === 'com.atproto.admin.defs#modEventComment' && meta?.sticky) {
newStatus.comment = comment
subjectStatus.comment = comment
}
if (blobCids?.length) {
const newBlobCids = sql<string[]>`${JSON.stringify(
blobCids.map((c) => c.toString()),
)}` as unknown as ModerationSubjectStatusRow['blobCids']
newStatus.blobCids = newBlobCids
subjectStatus.blobCids = newBlobCids
}
const insertQuery = db.db
.insertInto('moderation_subject_status')
.values({
...identifier,
...newStatus,
createdAt: now,
updatedAt: now,
// TODO: Need to get the types right here.
} as ModerationSubjectStatusRow)
.onConflict((oc) =>
oc.constraint('moderation_status_unique_idx').doUpdateSet({
...subjectStatus,
updatedAt: now,
}),
)
const status = await insertQuery.executeTakeFirst()
return status
}
type ModerationSubjectStatusFilter =
| Pick<ModerationSubjectStatus, 'did'>
| Pick<ModerationSubjectStatus, 'did' | 'recordPath'>
| Pick<ModerationSubjectStatus, 'did' | 'recordPath' | 'recordCid'>
export const getModerationSubjectStatus = async (
db: PrimaryDatabase,
filters: ModerationSubjectStatusFilter,
) => {
let builder = db.db
.selectFrom('moderation_subject_status')
// DID will always be passed at the very least
.where('did', '=', filters.did)
.where('recordPath', '=', 'recordPath' in filters ? filters.recordPath : '')
if ('recordCid' in filters) {
builder = builder.where('recordCid', '=', filters.recordCid)
} else {
builder = builder.where('recordCid', 'is', null)
}
return builder.executeTakeFirst()
}
export const getStatusIdentifierFromSubject = (
subject: string | AtUri,
): { did: string; recordPath: string } => {
const isSubjectString = typeof subject === 'string'
if (isSubjectString && subject.startsWith('did:')) {
return {
did: subject,
recordPath: '',
}
}
if (isSubjectString && !subject.startsWith('at://')) {
throw new Error('Subject is neither a did nor an at-uri')
}
const uri = isSubjectString ? new AtUri(subject) : subject
return {
did: uri.host,
recordPath: `${uri.collection}/${uri.rkey}`,
}
}

@ -0,0 +1,49 @@
import { Selectable } from 'kysely'
import {
ModerationEvent,
ModerationSubjectStatus,
} from '../../db/tables/moderation'
import { AtUri } from '@atproto/syntax'
import { CID } from 'multiformats/cid'
import { ComAtprotoAdminDefs } from '@atproto/api'
export type SubjectInfo =
| {
subjectType: 'com.atproto.admin.defs#repoRef'
subjectDid: string
subjectUri: null
subjectCid: null
}
| {
subjectType: 'com.atproto.repo.strongRef'
subjectDid: string
subjectUri: string
subjectCid: string
}
export type ModerationEventRow = Selectable<ModerationEvent>
export type ReversibleModerationEvent = Pick<
ModerationEventRow,
'createdBy' | 'comment' | 'action'
> & {
createdAt?: Date
subject: { did: string } | { uri: AtUri; cid: CID }
}
export type ModerationEventRowWithHandle = ModerationEventRow & {
subjectHandle?: string | null
creatorHandle?: string | null
}
export type ModerationSubjectStatusRow = Selectable<ModerationSubjectStatus>
export type ModerationSubjectStatusRowWithHandle =
ModerationSubjectStatusRow & { handle: string | null }
export type ModEventType =
| ComAtprotoAdminDefs.ModEventTakedown
| ComAtprotoAdminDefs.ModEventAcknowledge
| ComAtprotoAdminDefs.ModEventEscalate
| ComAtprotoAdminDefs.ModEventComment
| ComAtprotoAdminDefs.ModEventLabel
| ComAtprotoAdminDefs.ModEventReport
| ComAtprotoAdminDefs.ModEventMute
| ComAtprotoAdminDefs.ModEventReverseTakedown

@ -1,4 +1,4 @@
import { Selectable } from 'kysely'
import { sql } from 'kysely'
import { ArrayEl } from '@atproto/common'
import { AtUri } from '@atproto/syntax'
import { INVALID_HANDLE } from '@atproto/syntax'
@ -6,22 +6,25 @@ import { BlobRef, jsonStringToLex } from '@atproto/lexicon'
import { Database } from '../../db'
import { Actor } from '../../db/tables/actor'
import { Record as RecordRow } from '../../db/tables/record'
import { ModerationAction } from '../../db/tables/moderation'
import {
ModEventView,
RepoView,
RepoViewDetail,
RecordView,
RecordViewDetail,
ActionView,
ActionViewDetail,
ReportView,
ReportViewDetail,
BlobView,
SubjectStatusView,
ModEventViewDetail,
} from '../../lexicon/types/com/atproto/admin/defs'
import { OutputSchema as ReportOutput } from '../../lexicon/types/com/atproto/moderation/createReport'
import { Label } from '../../lexicon/types/com/atproto/label/defs'
import { ModerationReportRowWithHandle } from '.'
import {
ModerationEventRowWithHandle,
ModerationSubjectStatusRowWithHandle,
} from './types'
import { getSelfLabels } from '../label'
import { REASONOTHER } from '../../lexicon/types/com/atproto/moderation/defs'
export class ModerationViews {
constructor(private db: Database) {}
@ -34,7 +37,7 @@ export class ModerationViews {
const results = Array.isArray(result) ? result : [result]
if (results.length === 0) return []
const [info, actionResults] = await Promise.all([
const [info, subjectStatuses] = await Promise.all([
await this.db.db
.selectFrom('actor')
.leftJoin('profile', 'profile.creator', 'actor.did')
@ -50,31 +53,21 @@ export class ModerationViews {
)
.select(['actor.did as did', 'profile_record.json as profileJson'])
.execute(),
this.db.db
.selectFrom('moderation_action')
.where('reversedAt', 'is', null)
.where('subjectType', '=', 'com.atproto.admin.defs#repoRef')
.where(
'subjectDid',
'in',
results.map((r) => r.did),
)
.select(['id', 'action', 'durationInHours', 'subjectDid'])
.execute(),
this.getSubjectStatus(results.map((r) => ({ did: r.did }))),
])
const infoByDid = info.reduce(
(acc, cur) => Object.assign(acc, { [cur.did]: cur }),
{} as Record<string, ArrayEl<typeof info>>,
)
const actionByDid = actionResults.reduce(
(acc, cur) => Object.assign(acc, { [cur.subjectDid ?? '']: cur }),
{} as Record<string, ArrayEl<typeof actionResults>>,
const subjectStatusByDid = subjectStatuses.reduce(
(acc, cur) =>
Object.assign(acc, { [cur.did ?? '']: this.subjectStatus(cur) }),
{},
)
const views = results.map((r) => {
const { profileJson } = infoByDid[r.did] ?? {}
const action = actionByDid[r.did]
const relatedRecords: object[] = []
if (profileJson) {
relatedRecords.push(
@ -88,49 +81,125 @@ export class ModerationViews {
relatedRecords,
indexedAt: r.indexedAt,
moderation: {
currentAction: action
? {
id: action.id,
action: action.action,
durationInHours: action.durationInHours ?? undefined,
}
: undefined,
subjectStatus: subjectStatusByDid[r.did] ?? undefined,
},
}
})
return Array.isArray(result) ? views : views[0]
}
event(result: EventResult): Promise<ModEventView>
event(result: EventResult[]): Promise<ModEventView[]>
async event(
result: EventResult | EventResult[],
): Promise<ModEventView | ModEventView[]> {
const results = Array.isArray(result) ? result : [result]
if (results.length === 0) return []
const views = results.map((res) => {
const eventView: ModEventView = {
id: res.id,
event: {
$type: res.action,
comment: res.comment ?? undefined,
},
subject:
res.subjectType === 'com.atproto.admin.defs#repoRef'
? {
$type: 'com.atproto.admin.defs#repoRef',
did: res.subjectDid,
}
: {
$type: 'com.atproto.repo.strongRef',
uri: res.subjectUri,
cid: res.subjectCid,
},
subjectBlobCids: [],
createdBy: res.createdBy,
createdAt: res.createdAt,
subjectHandle: res.subjectHandle ?? undefined,
creatorHandle: res.creatorHandle ?? undefined,
}
if (
[
'com.atproto.admin.defs#modEventTakedown',
'com.atproto.admin.defs#modEventMute',
].includes(res.action)
) {
eventView.event = {
...eventView.event,
durationInHours: res.durationInHours ?? undefined,
}
}
if (res.action === 'com.atproto.admin.defs#modEventLabel') {
eventView.event = {
...eventView.event,
createLabelVals: res.createLabelVals?.length
? res.createLabelVals.split(' ')
: [],
negateLabelVals: res.negateLabelVals?.length
? res.negateLabelVals.split(' ')
: [],
}
}
if (res.action === 'com.atproto.admin.defs#modEventReport') {
eventView.event = {
...eventView.event,
reportType: res.meta?.reportType ?? undefined,
}
}
if (res.action === 'com.atproto.admin.defs#modEventEmail') {
eventView.event = {
...eventView.event,
subject: res.meta?.subject ?? undefined,
}
}
if (
res.action === 'com.atproto.admin.defs#modEventComment' &&
res.meta?.sticky
) {
eventView.event.sticky = true
}
return eventView
})
return Array.isArray(result) ? views : views[0]
}
async eventDetail(result: EventResult): Promise<ModEventViewDetail> {
const [event, subject] = await Promise.all([
this.event(result),
this.subject(result),
])
const allBlobs = findBlobRefs(subject.value)
const subjectBlobs = await this.blob(
allBlobs.filter((blob) =>
event.subjectBlobCids.includes(blob.ref.toString()),
),
)
return {
...event,
subject,
subjectBlobs,
}
}
async repoDetail(result: RepoResult): Promise<RepoViewDetail> {
const repo = await this.repo(result)
const [reportResults, actionResults] = await Promise.all([
this.db.db
.selectFrom('moderation_report')
.where('subjectType', '=', 'com.atproto.admin.defs#repoRef')
.where('subjectDid', '=', repo.did)
.orderBy('id', 'desc')
.selectAll()
.execute(),
this.db.db
.selectFrom('moderation_action')
.where('subjectType', '=', 'com.atproto.admin.defs#repoRef')
.where('subjectDid', '=', repo.did)
.orderBy('id', 'desc')
.selectAll()
.execute(),
])
const [reports, actions, labels] = await Promise.all([
this.report(reportResults),
this.action(actionResults),
this.labels(repo.did),
const [repo, labels] = await Promise.all([
this.repo(result),
this.labels(result.did),
])
return {
...repo,
moderation: {
...repo.moderation,
reports,
actions,
},
labels,
}
@ -144,7 +213,7 @@ export class ModerationViews {
const results = Array.isArray(result) ? result : [result]
if (results.length === 0) return []
const [repoResults, actionResults] = await Promise.all([
const [repoResults, subjectStatuses] = await Promise.all([
this.db.db
.selectFrom('actor')
.where(
@ -154,17 +223,7 @@ export class ModerationViews {
)
.selectAll()
.execute(),
this.db.db
.selectFrom('moderation_action')
.where('reversedAt', 'is', null)
.where('subjectType', '=', 'com.atproto.repo.strongRef')
.where(
'subjectUri',
'in',
results.map((r) => r.uri),
)
.select(['id', 'action', 'durationInHours', 'subjectUri'])
.execute(),
this.getSubjectStatus(results.map((r) => didAndRecordPathFromUri(r.uri))),
])
const repos = await this.repo(repoResults)
@ -172,14 +231,18 @@ export class ModerationViews {
(acc, cur) => Object.assign(acc, { [cur.did]: cur }),
{} as Record<string, ArrayEl<typeof repos>>,
)
const actionByUri = actionResults.reduce(
(acc, cur) => Object.assign(acc, { [cur.subjectUri ?? '']: cur }),
{} as Record<string, ArrayEl<typeof actionResults>>,
const subjectStatusByUri = subjectStatuses.reduce(
(acc, cur) =>
Object.assign(acc, {
[`${cur.did}/${cur.recordPath}` ?? '']: this.subjectStatus(cur),
}),
{},
)
const views = results.map((res) => {
const repo = reposByDid[didFromUri(res.uri)]
const action = actionByUri[res.uri]
const { did, recordPath } = didAndRecordPathFromUri(res.uri)
const subjectStatus = subjectStatusByUri[`${did}/${recordPath}`]
if (!repo) throw new Error(`Record repo is missing: ${res.uri}`)
const value = jsonStringToLex(res.json) as Record<string, unknown>
return {
@ -190,13 +253,7 @@ export class ModerationViews {
indexedAt: res.indexedAt,
repo,
moderation: {
currentAction: action
? {
id: action.id,
action: action.action,
durationInHours: action.durationInHours ?? undefined,
}
: undefined,
subjectStatus,
},
}
})
@ -205,29 +262,17 @@ export class ModerationViews {
}
async recordDetail(result: RecordResult): Promise<RecordViewDetail> {
const [record, reportResults, actionResults] = await Promise.all([
const [record, subjectStatusResult] = await Promise.all([
this.record(result),
this.db.db
.selectFrom('moderation_report')
.where('subjectType', '=', 'com.atproto.repo.strongRef')
.where('subjectUri', '=', result.uri)
.leftJoin('actor', 'actor.did', 'moderation_report.subjectDid')
.orderBy('id', 'desc')
.selectAll()
.execute(),
this.db.db
.selectFrom('moderation_action')
.where('subjectType', '=', 'com.atproto.repo.strongRef')
.where('subjectUri', '=', result.uri)
.orderBy('id', 'desc')
.selectAll()
.execute(),
this.getSubjectStatus(didAndRecordPathFromUri(result.uri)),
])
const [reports, actions, blobs, labels] = await Promise.all([
this.report(reportResults),
this.action(actionResults),
const [blobs, labels, subjectStatus] = await Promise.all([
this.blob(findBlobRefs(record.value)),
this.labels(record.uri),
subjectStatusResult?.length
? this.subjectStatus(subjectStatusResult[0])
: Promise.resolve(undefined),
])
const selfLabels = getSelfLabels({
uri: result.uri,
@ -239,196 +284,22 @@ export class ModerationViews {
blobs,
moderation: {
...record.moderation,
reports,
actions,
subjectStatus,
},
labels: [...labels, ...selfLabels],
}
}
action(result: ActionResult): Promise<ActionView>
action(result: ActionResult[]): Promise<ActionView[]>
async action(
result: ActionResult | ActionResult[],
): Promise<ActionView | ActionView[]> {
const results = Array.isArray(result) ? result : [result]
if (results.length === 0) return []
const [resolutions, subjectBlobResults] = await Promise.all([
this.db.db
.selectFrom('moderation_report_resolution')
.select(['reportId as id', 'actionId'])
.where(
'actionId',
'in',
results.map((r) => r.id),
)
.orderBy('id', 'desc')
.execute(),
await this.db.db
.selectFrom('moderation_action_subject_blob')
.selectAll()
.where(
'actionId',
'in',
results.map((r) => r.id),
)
.execute(),
])
const reportIdsByActionId = resolutions.reduce((acc, cur) => {
acc[cur.actionId] ??= []
acc[cur.actionId].push(cur.id)
return acc
}, {} as Record<string, number[]>)
const subjectBlobCidsByActionId = subjectBlobResults.reduce((acc, cur) => {
acc[cur.actionId] ??= []
acc[cur.actionId].push(cur.cid)
return acc
}, {} as Record<string, string[]>)
const views = results.map((res) => ({
id: res.id,
action: res.action,
durationInHours: res.durationInHours ?? undefined,
subject:
res.subjectType === 'com.atproto.admin.defs#repoRef'
? {
$type: 'com.atproto.admin.defs#repoRef',
did: res.subjectDid,
}
: {
$type: 'com.atproto.repo.strongRef',
uri: res.subjectUri,
cid: res.subjectCid,
},
subjectBlobCids: subjectBlobCidsByActionId[res.id] ?? [],
reason: res.reason,
createdAt: res.createdAt,
createdBy: res.createdBy,
createLabelVals:
res.createLabelVals && res.createLabelVals.length > 0
? res.createLabelVals.split(' ')
: undefined,
negateLabelVals:
res.negateLabelVals && res.negateLabelVals.length > 0
? res.negateLabelVals.split(' ')
: undefined,
reversal:
res.reversedAt !== null &&
res.reversedBy !== null &&
res.reversedReason !== null
? {
createdAt: res.reversedAt,
createdBy: res.reversedBy,
reason: res.reversedReason,
}
: undefined,
resolvedReportIds: reportIdsByActionId[res.id] ?? [],
}))
return Array.isArray(result) ? views : views[0]
}
async actionDetail(result: ActionResult): Promise<ActionViewDetail> {
const action = await this.action(result)
const reportResults = action.resolvedReportIds.length
? await this.db.db
.selectFrom('moderation_report')
.where('id', 'in', action.resolvedReportIds)
.orderBy('id', 'desc')
.selectAll()
.execute()
: []
const [subject, resolvedReports] = await Promise.all([
this.subject(result),
this.report(reportResults),
])
const allBlobs = findBlobRefs(subject.value)
const subjectBlobs = await this.blob(
allBlobs.filter((blob) =>
action.subjectBlobCids.includes(blob.ref.toString()),
),
)
return {
id: action.id,
action: action.action,
durationInHours: action.durationInHours,
subject,
subjectBlobs,
createLabelVals: action.createLabelVals,
negateLabelVals: action.negateLabelVals,
reason: action.reason,
createdAt: action.createdAt,
createdBy: action.createdBy,
reversal: action.reversal,
resolvedReports,
}
}
report(result: ReportResult): Promise<ReportView>
report(result: ReportResult[]): Promise<ReportView[]>
async report(
result: ReportResult | ReportResult[],
): Promise<ReportView | ReportView[]> {
const results = Array.isArray(result) ? result : [result]
if (results.length === 0) return []
const resolutions = await this.db.db
.selectFrom('moderation_report_resolution')
.select(['actionId as id', 'reportId'])
.where(
'reportId',
'in',
results.map((r) => r.id),
)
.orderBy('id', 'desc')
.execute()
const actionIdsByReportId = resolutions.reduce((acc, cur) => {
acc[cur.reportId] ??= []
acc[cur.reportId].push(cur.id)
return acc
}, {} as Record<string, number[]>)
const views: ReportView[] = results.map((res) => {
const decoratedView: ReportView = {
id: res.id,
createdAt: res.createdAt,
reasonType: res.reasonType,
reason: res.reason ?? undefined,
reportedBy: res.reportedByDid,
subject:
res.subjectType === 'com.atproto.admin.defs#repoRef'
? {
$type: 'com.atproto.admin.defs#repoRef',
did: res.subjectDid,
}
: {
$type: 'com.atproto.repo.strongRef',
uri: res.subjectUri,
cid: res.subjectCid,
},
resolvedByActionIds: actionIdsByReportId[res.id] ?? [],
}
if (res.handle) {
decoratedView.subjectRepoHandle = res.handle
}
return decoratedView
})
return Array.isArray(result) ? views : views[0]
}
reportPublic(report: ReportResult): ReportOutput {
return {
id: report.id,
createdAt: report.createdAt,
reasonType: report.reasonType,
reason: report.reason ?? undefined,
reportedBy: report.reportedByDid,
// Ideally, we would never have a report entry that does not have a reasonType but at the schema level
// we are not guarantying that so in whatever case, if we end up with such entries, default to 'other'
reasonType: report.meta?.reportType
? (report.meta?.reportType as string)
: REASONOTHER,
reason: report.comment ?? undefined,
reportedBy: report.createdBy,
subject:
report.subjectType === 'com.atproto.admin.defs#repoRef'
? {
@ -442,32 +313,6 @@ export class ModerationViews {
},
}
}
async reportDetail(result: ReportResult): Promise<ReportViewDetail> {
const report = await this.report(result)
const actionResults = report.resolvedByActionIds.length
? await this.db.db
.selectFrom('moderation_action')
.where('id', 'in', report.resolvedByActionIds)
.orderBy('id', 'desc')
.selectAll()
.execute()
: []
const [subject, resolvedByActions] = await Promise.all([
this.subject(result),
this.action(actionResults),
])
return {
id: report.id,
createdAt: report.createdAt,
reasonType: report.reasonType,
reason: report.reason ?? undefined,
reportedBy: report.reportedBy,
subject,
resolvedByActions,
}
}
// Partial view for subjects
async subject(result: SubjectResult): Promise<SubjectView> {
@ -511,44 +356,35 @@ export class ModerationViews {
async blob(blobs: BlobRef[]): Promise<BlobView[]> {
if (!blobs.length) return []
const actionResults = await this.db.db
.selectFrom('moderation_action')
.where('reversedAt', 'is', null)
.innerJoin(
'moderation_action_subject_blob as subject_blob',
'subject_blob.actionId',
'moderation_action.id',
)
const { ref } = this.db.db.dynamic
const modStatusResults = await this.db.db
.selectFrom('moderation_subject_status')
.where(
'subject_blob.cid',
'in',
blobs.map((blob) => blob.ref.toString()),
sql<string>`${ref(
'moderation_subject_status.blobCids',
)} @> ${JSON.stringify(blobs.map((blob) => blob.ref.toString()))}`,
)
.select(['id', 'action', 'durationInHours', 'cid'])
.execute()
const actionByCid = actionResults.reduce(
(acc, cur) => Object.assign(acc, { [cur.cid]: cur }),
{} as Record<string, ArrayEl<typeof actionResults>>,
.selectAll()
.executeTakeFirst()
const statusByCid = (modStatusResults?.blobCids || [])?.reduce(
(acc, cur) => Object.assign(acc, { [cur]: modStatusResults }),
{},
)
// Intentionally missing details field, since we don't have any on appview.
// We also don't know when the blob was created, so we use a canned creation time.
const unknownTime = new Date(0).toISOString()
return blobs.map((blob) => {
const cid = blob.ref.toString()
const action = actionByCid[cid]
const subjectStatus = statusByCid[cid]
? this.subjectStatus(statusByCid[cid])
: undefined
return {
cid,
mimeType: blob.mimeType,
size: blob.size,
createdAt: unknownTime,
moderation: {
currentAction: action
? {
id: action.id,
action: action.action,
durationInHours: action.durationInHours ?? undefined,
}
: undefined,
subjectStatus,
},
}
})
@ -567,27 +403,117 @@ export class ModerationViews {
neg: l.neg,
}))
}
async getSubjectStatus(
subject:
| { did: string; recordPath?: string }
| { did: string; recordPath?: string }[],
): Promise<ModerationSubjectStatusRowWithHandle[]> {
const subjectFilters = Array.isArray(subject) ? subject : [subject]
const filterForSubject =
({ did, recordPath }: { did: string; recordPath?: string }) =>
// TODO: Fix the typing here?
(clause: any) => {
clause = clause
.where('moderation_subject_status.did', '=', did)
.where('moderation_subject_status.recordPath', '=', recordPath || '')
return clause
}
const builder = this.db.db
.selectFrom('moderation_subject_status')
.leftJoin('actor', 'actor.did', 'moderation_subject_status.did')
.where((clause) => {
subjectFilters.forEach(({ did, recordPath }, i) => {
const applySubjectFilter = filterForSubject({ did, recordPath })
if (i === 0) {
clause = clause.where(applySubjectFilter)
} else {
clause = clause.orWhere(applySubjectFilter)
}
})
return clause
})
.selectAll('moderation_subject_status')
.select('actor.handle as handle')
return builder.execute()
}
subjectStatus(result: ModerationSubjectStatusRowWithHandle): SubjectStatusView
subjectStatus(
result: ModerationSubjectStatusRowWithHandle[],
): SubjectStatusView[]
subjectStatus(
result:
| ModerationSubjectStatusRowWithHandle
| ModerationSubjectStatusRowWithHandle[],
): SubjectStatusView | SubjectStatusView[] {
const results = Array.isArray(result) ? result : [result]
if (results.length === 0) return []
const decoratedSubjectStatuses = results.map((subjectStatus) => ({
id: subjectStatus.id,
reviewState: subjectStatus.reviewState,
createdAt: subjectStatus.createdAt,
updatedAt: subjectStatus.updatedAt,
comment: subjectStatus.comment ?? undefined,
lastReviewedBy: subjectStatus.lastReviewedBy ?? undefined,
lastReviewedAt: subjectStatus.lastReviewedAt ?? undefined,
lastReportedAt: subjectStatus.lastReportedAt ?? undefined,
muteUntil: subjectStatus.muteUntil ?? undefined,
suspendUntil: subjectStatus.suspendUntil ?? undefined,
takendown: subjectStatus.takendown ?? undefined,
subjectRepoHandle: subjectStatus.handle ?? undefined,
subjectBlobCids: subjectStatus.blobCids || [],
subject: !subjectStatus.recordPath
? {
$type: 'com.atproto.admin.defs#repoRef',
did: subjectStatus.did,
}
: {
$type: 'com.atproto.repo.strongRef',
uri: AtUri.make(
subjectStatus.did,
// Not too intuitive but the recordpath is basically <collection>/<rkey>
// which is what the last 2 params of .make() arguments are
...subjectStatus.recordPath.split('/'),
).toString(),
cid: subjectStatus.recordCid,
},
}))
return Array.isArray(result)
? decoratedSubjectStatuses
: decoratedSubjectStatuses[0]
}
}
type RepoResult = Actor
type ActionResult = Selectable<ModerationAction>
type EventResult = ModerationEventRowWithHandle
type ReportResult = ModerationReportRowWithHandle
type ReportResult = ModerationEventRowWithHandle
type RecordResult = RecordRow
type SubjectResult = Pick<
ActionResult & ReportResult,
EventResult & ReportResult,
'id' | 'subjectType' | 'subjectDid' | 'subjectUri' | 'subjectCid'
>
type SubjectView = ActionViewDetail['subject'] & ReportViewDetail['subject']
type SubjectView = ModEventViewDetail['subject'] & ReportViewDetail['subject']
function didFromUri(uri: string) {
return new AtUri(uri).host
}
function didAndRecordPathFromUri(uri: string) {
const atUri = new AtUri(uri)
return { did: atUri.host, recordPath: `${atUri.collection}/${atUri.rkey}` }
}
function findBlobRefs(value: unknown, refs: BlobRef[] = []) {
if (value instanceof BlobRef) {
refs.push(value)

@ -1,172 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`admin get moderation action view gets moderation action for a record. 1`] = `
Object {
"action": "com.atproto.admin.defs#takedown",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 2,
"reason": "X",
"resolvedReports": Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 2,
"reason": "defamation",
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(1)",
"resolvedByActionIds": Array [
2,
1,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
},
],
"subject": Object {
"$type": "com.atproto.admin.defs#recordView",
"blobCids": Array [],
"cid": "cids(0)",
"indexedAt": "1970-01-01T00:00:00.000Z",
"moderation": Object {
"currentAction": Object {
"action": "com.atproto.admin.defs#takedown",
"id": 2,
},
},
"repo": Object {
"did": "user(0)",
"email": "alice@test.com",
"handle": "alice.test",
"indexedAt": "1970-01-01T00:00:00.000Z",
"invitesDisabled": false,
"moderation": Object {},
"relatedRecords": Array [
Object {
"$type": "app.bsky.actor.profile",
"avatar": Object {
"$type": "blob",
"mimeType": "image/jpeg",
"ref": Object {
"$link": "cids(1)",
},
"size": 3976,
},
"description": "its me!",
"displayName": "ali",
"labels": Object {
"$type": "com.atproto.label.defs#selfLabels",
"values": Array [
Object {
"val": "self-label-a",
},
Object {
"val": "self-label-b",
},
],
},
},
],
},
"uri": "record(0)",
"value": Object {
"$type": "app.bsky.feed.post",
"createdAt": "1970-01-01T00:00:00.000Z",
"labels": Object {
"$type": "com.atproto.label.defs#selfLabels",
"values": Array [
Object {
"val": "self-label",
},
],
},
"text": "hey there",
},
},
"subjectBlobs": Array [],
}
`;
exports[`admin get moderation action view gets moderation action for a repo. 1`] = `
Object {
"action": "com.atproto.admin.defs#flag",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 1,
"reason": "X",
"resolvedReports": Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 2,
"reason": "defamation",
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(1)",
"resolvedByActionIds": Array [
2,
1,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(1)",
"uri": "record(0)",
},
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 1,
"reasonType": "com.atproto.moderation.defs#reasonSpam",
"reportedBy": "user(2)",
"resolvedByActionIds": Array [
1,
],
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(0)",
},
},
],
"reversal": Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"reason": "X",
},
"subject": Object {
"$type": "com.atproto.admin.defs#repoView",
"did": "user(0)",
"email": "alice@test.com",
"handle": "alice.test",
"indexedAt": "1970-01-01T00:00:00.000Z",
"invitesDisabled": false,
"moderation": Object {},
"relatedRecords": Array [
Object {
"$type": "app.bsky.actor.profile",
"avatar": Object {
"$type": "blob",
"mimeType": "image/jpeg",
"ref": Object {
"$link": "cids(0)",
},
"size": 3976,
},
"description": "its me!",
"displayName": "ali",
"labels": Object {
"$type": "com.atproto.label.defs#selfLabels",
"values": Array [
Object {
"val": "self-label-a",
},
Object {
"val": "self-label-b",
},
],
},
},
],
},
"subjectBlobs": Array [],
}
`;

@ -1,178 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`admin get moderation actions view gets all moderation actions for a record. 1`] = `
Array [
Object {
"action": "com.atproto.admin.defs#flag",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 1,
"reason": "X",
"resolvedReportIds": Array [
1,
],
"reversal": Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"reason": "X",
},
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectBlobCids": Array [],
},
]
`;
exports[`admin get moderation actions view gets all moderation actions for a repo. 1`] = `
Array [
Object {
"action": "com.atproto.admin.defs#flag",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 5,
"reason": "X",
"resolvedReportIds": Array [
3,
],
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(0)",
},
"subjectBlobCids": Array [],
},
Object {
"action": "com.atproto.admin.defs#acknowledge",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 2,
"reason": "X",
"resolvedReportIds": Array [],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectBlobCids": Array [],
},
Object {
"action": "com.atproto.admin.defs#flag",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 1,
"reason": "X",
"resolvedReportIds": Array [
1,
],
"reversal": Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"reason": "X",
},
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(1)",
"uri": "record(1)",
},
"subjectBlobCids": Array [],
},
]
`;
exports[`admin get moderation actions view gets all moderation actions. 1`] = `
Array [
Object {
"action": "com.atproto.admin.defs#acknowledge",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 6,
"reason": "X",
"resolvedReportIds": Array [],
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(0)",
},
"subjectBlobCids": Array [],
},
Object {
"action": "com.atproto.admin.defs#flag",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 5,
"reason": "X",
"resolvedReportIds": Array [
3,
],
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(1)",
},
"subjectBlobCids": Array [],
},
Object {
"action": "com.atproto.admin.defs#flag",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 4,
"reason": "X",
"resolvedReportIds": Array [],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectBlobCids": Array [],
},
Object {
"action": "com.atproto.admin.defs#takedown",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 3,
"reason": "X",
"resolvedReportIds": Array [],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(1)",
"uri": "record(1)",
},
"subjectBlobCids": Array [],
},
Object {
"action": "com.atproto.admin.defs#acknowledge",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 2,
"reason": "X",
"resolvedReportIds": Array [],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(2)",
"uri": "record(2)",
},
"subjectBlobCids": Array [],
},
Object {
"action": "com.atproto.admin.defs#flag",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 1,
"reason": "X",
"resolvedReportIds": Array [
1,
],
"reversal": Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"reason": "X",
},
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(3)",
"uri": "record(3)",
},
"subjectBlobCids": Array [],
},
]
`;

@ -1,177 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`admin get moderation action view gets moderation report for a record. 1`] = `
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 2,
"reason": "defamation",
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(0)",
"resolvedByActions": Array [
Object {
"action": "com.atproto.admin.defs#takedown",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 2,
"reason": "X",
"resolvedReportIds": Array [
2,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectBlobCids": Array [],
},
Object {
"action": "com.atproto.admin.defs#flag",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 1,
"reason": "X",
"resolvedReportIds": Array [
2,
1,
],
"reversal": Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"reason": "X",
},
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(1)",
},
"subjectBlobCids": Array [],
},
],
"subject": Object {
"$type": "com.atproto.admin.defs#recordView",
"blobCids": Array [],
"cid": "cids(0)",
"indexedAt": "1970-01-01T00:00:00.000Z",
"moderation": Object {
"currentAction": Object {
"action": "com.atproto.admin.defs#takedown",
"id": 2,
},
},
"repo": Object {
"did": "user(1)",
"email": "alice@test.com",
"handle": "alice.test",
"indexedAt": "1970-01-01T00:00:00.000Z",
"invitesDisabled": false,
"moderation": Object {},
"relatedRecords": Array [
Object {
"$type": "app.bsky.actor.profile",
"avatar": Object {
"$type": "blob",
"mimeType": "image/jpeg",
"ref": Object {
"$link": "cids(1)",
},
"size": 3976,
},
"description": "its me!",
"displayName": "ali",
"labels": Object {
"$type": "com.atproto.label.defs#selfLabels",
"values": Array [
Object {
"val": "self-label-a",
},
Object {
"val": "self-label-b",
},
],
},
},
],
},
"uri": "record(0)",
"value": Object {
"$type": "app.bsky.feed.post",
"createdAt": "1970-01-01T00:00:00.000Z",
"labels": Object {
"$type": "com.atproto.label.defs#selfLabels",
"values": Array [
Object {
"val": "self-label",
},
],
},
"text": "hey there",
},
},
}
`;
exports[`admin get moderation action view gets moderation report for a repo. 1`] = `
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 1,
"reasonType": "com.atproto.moderation.defs#reasonSpam",
"reportedBy": "user(0)",
"resolvedByActions": Array [
Object {
"action": "com.atproto.admin.defs#flag",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 1,
"reason": "X",
"resolvedReportIds": Array [
2,
1,
],
"reversal": Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"reason": "X",
},
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(1)",
},
"subjectBlobCids": Array [],
},
],
"subject": Object {
"$type": "com.atproto.admin.defs#repoView",
"did": "user(1)",
"email": "alice@test.com",
"handle": "alice.test",
"indexedAt": "1970-01-01T00:00:00.000Z",
"invitesDisabled": false,
"moderation": Object {},
"relatedRecords": Array [
Object {
"$type": "app.bsky.actor.profile",
"avatar": Object {
"$type": "blob",
"mimeType": "image/jpeg",
"ref": Object {
"$link": "cids(0)",
},
"size": 3976,
},
"description": "its me!",
"displayName": "ali",
"labels": Object {
"$type": "com.atproto.label.defs#selfLabels",
"values": Array [
Object {
"val": "self-label-a",
},
Object {
"val": "self-label-b",
},
],
},
},
],
},
}
`;

@ -1,307 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`admin get moderation reports view gets all moderation reports actioned by a certain moderator. 1`] = `
Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 1,
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(0)",
"resolvedByActionIds": Array [
1,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectRepoHandle": "alice.test",
},
]
`;
exports[`admin get moderation reports view gets all moderation reports actioned by a certain moderator. 2`] = `
Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 3,
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(0)",
"resolvedByActionIds": Array [
3,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectRepoHandle": "bob.test",
},
]
`;
exports[`admin get moderation reports view gets all moderation reports by active resolution action type. 1`] = `
Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 3,
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(0)",
"resolvedByActionIds": Array [
3,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectRepoHandle": "bob.test",
},
]
`;
exports[`admin get moderation reports view gets all moderation reports for a record. 1`] = `
Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 1,
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(0)",
"resolvedByActionIds": Array [
1,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectRepoHandle": "alice.test",
},
]
`;
exports[`admin get moderation reports view gets all moderation reports for a repo. 1`] = `
Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 5,
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(0)",
"resolvedByActionIds": Array [
5,
],
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(0)",
},
"subjectRepoHandle": "alice.test",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 2,
"reasonType": "com.atproto.moderation.defs#reasonSpam",
"reportedBy": "user(1)",
"resolvedByActionIds": Array [],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectRepoHandle": "alice.test",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 1,
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(0)",
"resolvedByActionIds": Array [
1,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(1)",
"uri": "record(1)",
},
"subjectRepoHandle": "alice.test",
},
]
`;
exports[`admin get moderation reports view gets all moderation reports. 1`] = `
Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 6,
"reasonType": "com.atproto.moderation.defs#reasonSpam",
"reportedBy": "user(0)",
"resolvedByActionIds": Array [],
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(0)",
},
"subjectRepoHandle": "carol.test",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 5,
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(1)",
"resolvedByActionIds": Array [
5,
],
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(1)",
},
"subjectRepoHandle": "alice.test",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 4,
"reasonType": "com.atproto.moderation.defs#reasonSpam",
"reportedBy": "user(0)",
"resolvedByActionIds": Array [],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectRepoHandle": "dan.test",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 3,
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(1)",
"resolvedByActionIds": Array [
3,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(1)",
"uri": "record(1)",
},
"subjectRepoHandle": "bob.test",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 2,
"reasonType": "com.atproto.moderation.defs#reasonSpam",
"reportedBy": "user(0)",
"resolvedByActionIds": Array [],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(2)",
"uri": "record(2)",
},
"subjectRepoHandle": "alice.test",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 1,
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(1)",
"resolvedByActionIds": Array [
1,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(3)",
"uri": "record(3)",
},
"subjectRepoHandle": "alice.test",
},
]
`;
exports[`admin get moderation reports view gets all resolved/unresolved moderation reports. 1`] = `
Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 5,
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(0)",
"resolvedByActionIds": Array [
5,
],
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(0)",
},
"subjectRepoHandle": "alice.test",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 3,
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(0)",
"resolvedByActionIds": Array [
3,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectRepoHandle": "bob.test",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 1,
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(0)",
"resolvedByActionIds": Array [
1,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(1)",
"uri": "record(1)",
},
"subjectRepoHandle": "alice.test",
},
]
`;
exports[`admin get moderation reports view gets all resolved/unresolved moderation reports. 2`] = `
Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 6,
"reasonType": "com.atproto.moderation.defs#reasonSpam",
"reportedBy": "user(0)",
"resolvedByActionIds": Array [],
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(0)",
},
"subjectRepoHandle": "carol.test",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 4,
"reasonType": "com.atproto.moderation.defs#reasonSpam",
"reportedBy": "user(0)",
"resolvedByActionIds": Array [],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectRepoHandle": "dan.test",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 2,
"reasonType": "com.atproto.moderation.defs#reasonSpam",
"reportedBy": "user(0)",
"resolvedByActionIds": Array [],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(1)",
"uri": "record(1)",
},
"subjectRepoHandle": "alice.test",
},
]
`;

@ -17,74 +17,23 @@ Object {
},
],
"moderation": Object {
"actions": Array [
Object {
"action": "com.atproto.admin.defs#takedown",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 2,
"reason": "X",
"resolvedReportIds": Array [],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectBlobCids": Array [],
"subjectStatus": Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 1,
"lastReportedAt": "1970-01-01T00:00:00.000Z",
"lastReviewedAt": "1970-01-01T00:00:00.000Z",
"lastReviewedBy": "did:example:admin",
"reviewState": "com.atproto.admin.defs#reviewClosed",
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
Object {
"action": "com.atproto.admin.defs#acknowledge",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 1,
"reason": "X",
"resolvedReportIds": Array [],
"reversal": Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"reason": "X",
},
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectBlobCids": Array [],
},
],
"currentAction": Object {
"action": "com.atproto.admin.defs#takedown",
"id": 2,
"subjectBlobCids": Array [],
"subjectRepoHandle": "alice.test",
"takendown": true,
"updatedAt": "1970-01-01T00:00:00.000Z",
},
"reports": Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 2,
"reason": "defamation",
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(1)",
"resolvedByActionIds": Array [],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectRepoHandle": "alice.test",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 1,
"reasonType": "com.atproto.moderation.defs#reasonSpam",
"reportedBy": "user(2)",
"resolvedByActionIds": Array [],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectRepoHandle": "alice.test",
},
],
},
"repo": Object {
"did": "user(0)",
@ -154,74 +103,23 @@ Object {
},
],
"moderation": Object {
"actions": Array [
Object {
"action": "com.atproto.admin.defs#takedown",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 2,
"reason": "X",
"resolvedReportIds": Array [],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectBlobCids": Array [],
"subjectStatus": Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 1,
"lastReportedAt": "1970-01-01T00:00:00.000Z",
"lastReviewedAt": "1970-01-01T00:00:00.000Z",
"lastReviewedBy": "did:example:admin",
"reviewState": "com.atproto.admin.defs#reviewClosed",
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
Object {
"action": "com.atproto.admin.defs#acknowledge",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 1,
"reason": "X",
"resolvedReportIds": Array [],
"reversal": Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"reason": "X",
},
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectBlobCids": Array [],
},
],
"currentAction": Object {
"action": "com.atproto.admin.defs#takedown",
"id": 2,
"subjectBlobCids": Array [],
"subjectRepoHandle": "alice.test",
"takendown": true,
"updatedAt": "1970-01-01T00:00:00.000Z",
},
"reports": Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 2,
"reason": "defamation",
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(1)",
"resolvedByActionIds": Array [],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectRepoHandle": "alice.test",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 1,
"reasonType": "com.atproto.moderation.defs#reasonSpam",
"reportedBy": "user(2)",
"resolvedByActionIds": Array [],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectRepoHandle": "alice.test",
},
],
},
"repo": Object {
"did": "user(0)",

@ -10,68 +10,22 @@ Object {
"invitesDisabled": false,
"labels": Array [],
"moderation": Object {
"actions": Array [
Object {
"action": "com.atproto.admin.defs#takedown",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 2,
"reason": "X",
"resolvedReportIds": Array [],
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(0)",
},
"subjectBlobCids": Array [],
"subjectStatus": Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 1,
"lastReportedAt": "1970-01-01T00:00:00.000Z",
"lastReviewedAt": "1970-01-01T00:00:00.000Z",
"lastReviewedBy": "did:example:admin",
"reviewState": "com.atproto.admin.defs#reviewClosed",
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(0)",
},
Object {
"action": "com.atproto.admin.defs#acknowledge",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 1,
"reason": "X",
"resolvedReportIds": Array [],
"reversal": Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"reason": "X",
},
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(0)",
},
"subjectBlobCids": Array [],
},
],
"currentAction": Object {
"action": "com.atproto.admin.defs#takedown",
"id": 2,
"subjectBlobCids": Array [],
"subjectRepoHandle": "alice.test",
"takendown": true,
"updatedAt": "1970-01-01T00:00:00.000Z",
},
"reports": Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 2,
"reason": "defamation",
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(1)",
"resolvedByActionIds": Array [],
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(0)",
},
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 1,
"reasonType": "com.atproto.moderation.defs#reasonSpam",
"reportedBy": "user(2)",
"resolvedByActionIds": Array [],
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(0)",
},
},
],
},
"relatedRecords": Array [
Object {

@ -0,0 +1,146 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`moderation-events get event gets an event by specific id 1`] = `
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "user(2)",
"event": Object {
"$type": "com.atproto.admin.defs#modEventReport",
"comment": "X",
"reportType": "com.atproto.moderation.defs#reasonMisleading",
},
"id": 1,
"subject": Object {
"$type": "com.atproto.admin.defs#repoView",
"did": "user(0)",
"handle": "alice.test",
"indexedAt": "1970-01-01T00:00:00.000Z",
"moderation": Object {
"subjectStatus": Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 1,
"lastReportedAt": "1970-01-01T00:00:00.000Z",
"lastReviewedAt": "1970-01-01T00:00:00.000Z",
"lastReviewedBy": "user(1)",
"reviewState": "com.atproto.admin.defs#reviewEscalated",
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(0)",
},
"subjectBlobCids": Array [],
"subjectRepoHandle": "alice.test",
"takendown": false,
"updatedAt": "1970-01-01T00:00:00.000Z",
},
},
"relatedRecords": Array [
Object {
"$type": "app.bsky.actor.profile",
"avatar": Object {
"$type": "blob",
"mimeType": "image/jpeg",
"ref": Object {
"$link": "cids(0)",
},
"size": 3976,
},
"description": "its me!",
"displayName": "ali",
"labels": Object {
"$type": "com.atproto.label.defs#selfLabels",
"values": Array [
Object {
"val": "self-label-a",
},
Object {
"val": "self-label-b",
},
],
},
},
],
},
"subjectBlobCids": Array [],
"subjectBlobs": Array [],
}
`;
exports[`moderation-events query events returns all events for record or repo 1`] = `
Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "user(1)",
"creatorHandle": "alice.test",
"event": Object {
"$type": "com.atproto.admin.defs#modEventReport",
"comment": "X",
"reportType": "com.atproto.moderation.defs#reasonSpam",
},
"id": 7,
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(0)",
},
"subjectBlobCids": Array [],
"subjectHandle": "bob.test",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "user(1)",
"creatorHandle": "alice.test",
"event": Object {
"$type": "com.atproto.admin.defs#modEventReport",
"comment": "X",
"reportType": "com.atproto.moderation.defs#reasonSpam",
},
"id": 3,
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(0)",
},
"subjectBlobCids": Array [],
"subjectHandle": "bob.test",
},
]
`;
exports[`moderation-events query events returns all events for record or repo 2`] = `
Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "user(0)",
"creatorHandle": "bob.test",
"event": Object {
"$type": "com.atproto.admin.defs#modEventReport",
"comment": "X",
"reportType": "com.atproto.moderation.defs#reasonSpam",
},
"id": 6,
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectBlobCids": Array [],
"subjectHandle": "alice.test",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "user(0)",
"creatorHandle": "bob.test",
"event": Object {
"$type": "com.atproto.admin.defs#modEventReport",
"comment": "X",
"reportType": "com.atproto.moderation.defs#reasonSpam",
},
"id": 2,
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectBlobCids": Array [],
"subjectHandle": "alice.test",
},
]
`;

@ -0,0 +1,64 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`moderation-statuses query statuses returns statuses for subjects that received moderation events 1`] = `
Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 4,
"lastReportedAt": "1970-01-01T00:00:00.000Z",
"reviewState": "com.atproto.admin.defs#reviewOpen",
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectBlobCids": Array [],
"subjectRepoHandle": "bob.test",
"takendown": false,
"updatedAt": "1970-01-01T00:00:00.000Z",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 3,
"lastReportedAt": "1970-01-01T00:00:00.000Z",
"reviewState": "com.atproto.admin.defs#reviewOpen",
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(0)",
},
"subjectBlobCids": Array [],
"subjectRepoHandle": "bob.test",
"takendown": false,
"updatedAt": "1970-01-01T00:00:00.000Z",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 2,
"lastReportedAt": "1970-01-01T00:00:00.000Z",
"reviewState": "com.atproto.admin.defs#reviewOpen",
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(1)",
"uri": "record(1)",
},
"subjectBlobCids": Array [],
"subjectRepoHandle": "alice.test",
"takendown": false,
"updatedAt": "1970-01-01T00:00:00.000Z",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 1,
"lastReportedAt": "1970-01-01T00:00:00.000Z",
"reviewState": "com.atproto.admin.defs#reviewOpen",
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(1)",
},
"subjectBlobCids": Array [],
"subjectRepoHandle": "alice.test",
"takendown": false,
"updatedAt": "1970-01-01T00:00:00.000Z",
},
]
`;

@ -1,130 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`moderation actioning resolves reports on missing repos and records. 1`] = `
Object {
"recordActionDetail": Object {
"action": "com.atproto.admin.defs#flag",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 2,
"reason": "Y",
"resolvedReports": Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 11,
"reason": "defamation",
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(0)",
"resolvedByActionIds": Array [
2,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 10,
"reasonType": "com.atproto.moderation.defs#reasonSpam",
"reportedBy": "user(1)",
"resolvedByActionIds": Array [
2,
],
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(2)",
},
},
],
"subject": Object {
"$type": "com.atproto.admin.defs#recordViewNotFound",
"uri": "record(0)",
},
"subjectBlobs": Array [],
},
"reportADetail": Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 10,
"reasonType": "com.atproto.moderation.defs#reasonSpam",
"reportedBy": "user(1)",
"resolvedByActions": Array [
Object {
"action": "com.atproto.admin.defs#flag",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 2,
"reason": "Y",
"resolvedReportIds": Array [
11,
10,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectBlobCids": Array [],
},
],
"subject": Object {
"$type": "com.atproto.admin.defs#repoViewNotFound",
"did": "user(2)",
},
},
"reportBDetail": Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 11,
"reason": "defamation",
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(0)",
"resolvedByActions": Array [
Object {
"action": "com.atproto.admin.defs#flag",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 2,
"reason": "Y",
"resolvedReportIds": Array [
11,
10,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectBlobCids": Array [],
},
],
"subject": Object {
"$type": "com.atproto.admin.defs#recordViewNotFound",
"uri": "record(0)",
},
},
}
`;
exports[`moderation actioning resolves reports on repos and records. 1`] = `
Object {
"action": "com.atproto.admin.defs#takedown",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 1,
"reason": "Y",
"resolvedReportIds": Array [
9,
8,
],
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(0)",
},
"subjectBlobCids": Array [],
}
`;
exports[`moderation reporting creates reports of a record. 1`] = `
Array [
Object {

@ -1,100 +0,0 @@
import { TestNetwork, SeedClient } from '@atproto/dev-env'
import AtpAgent from '@atproto/api'
import {
FLAG,
TAKEDOWN,
} from '@atproto/api/src/client/types/com/atproto/admin/defs'
import {
REASONOTHER,
REASONSPAM,
} from '../../src/lexicon/types/com/atproto/moderation/defs'
import { forSnapshot } from '../_util'
import basicSeed from '../seeds/basic'
describe('admin get moderation action view', () => {
let network: TestNetwork
let agent: AtpAgent
let sc: SeedClient
beforeAll(async () => {
network = await TestNetwork.create({
dbPostgresSchema: 'views_admin_get_moderation_action',
})
agent = network.pds.getClient()
sc = network.getSeedClient()
await basicSeed(sc)
})
afterAll(async () => {
await network.close()
})
beforeAll(async () => {
const reportRepo = await sc.createReport({
reportedBy: sc.dids.bob,
reasonType: REASONSPAM,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.alice,
},
})
const reportRecord = await sc.createReport({
reportedBy: sc.dids.carol,
reasonType: REASONOTHER,
reason: 'defamation',
subject: {
$type: 'com.atproto.repo.strongRef',
uri: sc.posts[sc.dids.alice][0].ref.uriStr,
cid: sc.posts[sc.dids.alice][0].ref.cidStr,
},
})
const flagRepo = await sc.takeModerationAction({
action: FLAG,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.alice,
},
})
const takedownRecord = await sc.takeModerationAction({
action: TAKEDOWN,
subject: {
$type: 'com.atproto.repo.strongRef',
uri: sc.posts[sc.dids.alice][0].ref.uriStr,
cid: sc.posts[sc.dids.alice][0].ref.cidStr,
},
})
await sc.resolveReports({
actionId: flagRepo.id,
reportIds: [reportRepo.id, reportRecord.id],
})
await sc.resolveReports({
actionId: takedownRecord.id,
reportIds: [reportRecord.id],
})
await sc.reverseModerationAction({ id: flagRepo.id })
})
it('gets moderation action for a repo.', async () => {
const result = await agent.api.com.atproto.admin.getModerationAction(
{ id: 1 },
{ headers: { authorization: network.pds.adminAuth() } },
)
expect(forSnapshot(result.data)).toMatchSnapshot()
})
it('gets moderation action for a record.', async () => {
const result = await agent.api.com.atproto.admin.getModerationAction(
{ id: 2 },
{ headers: { authorization: network.pds.adminAuth() } },
)
expect(forSnapshot(result.data)).toMatchSnapshot()
})
it('fails when moderation action does not exist.', async () => {
const promise = agent.api.com.atproto.admin.getModerationAction(
{ id: 100 },
{ headers: { authorization: network.pds.adminAuth() } },
)
await expect(promise).rejects.toThrow('Action not found')
})
})

@ -1,164 +0,0 @@
import { SeedClient, TestNetwork } from '@atproto/dev-env'
import AtpAgent from '@atproto/api'
import {
ACKNOWLEDGE,
FLAG,
TAKEDOWN,
} from '@atproto/api/src/client/types/com/atproto/admin/defs'
import {
REASONOTHER,
REASONSPAM,
} from '../../src/lexicon/types/com/atproto/moderation/defs'
import { forSnapshot, paginateAll } from '../_util'
import basicSeed from '../seeds/basic'
describe('admin get moderation actions view', () => {
let network: TestNetwork
let agent: AtpAgent
let sc: SeedClient
beforeAll(async () => {
network = await TestNetwork.create({
dbPostgresSchema: 'views_admin_get_moderation_actions',
})
agent = network.pds.getClient()
sc = network.getSeedClient()
await basicSeed(sc)
})
afterAll(async () => {
await network.close()
})
beforeAll(async () => {
const oneIn = (n) => (_, i) => i % n === 0
const getAction = (i) => [FLAG, ACKNOWLEDGE, TAKEDOWN][i % 3]
const posts = Object.values(sc.posts)
.flatMap((x) => x)
.filter(oneIn(2))
const dids = Object.values(sc.dids).filter(oneIn(2))
// Take actions on records
const recordActions: Awaited<ReturnType<typeof sc.takeModerationAction>>[] =
[]
for (let i = 0; i < posts.length; ++i) {
const post = posts[i]
recordActions.push(
await sc.takeModerationAction({
action: getAction(i),
subject: {
$type: 'com.atproto.repo.strongRef',
uri: post.ref.uriStr,
cid: post.ref.cidStr,
},
}),
)
}
// Reverse an action
await sc.reverseModerationAction({
id: recordActions[0].id,
})
// Take actions on repos
const repoActions: Awaited<ReturnType<typeof sc.takeModerationAction>>[] =
[]
for (let i = 0; i < dids.length; ++i) {
const did = dids[i]
repoActions.push(
await sc.takeModerationAction({
action: getAction(i),
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did,
},
}),
)
}
// Back some of the actions with a report, possibly resolved
const someRecordActions = recordActions.filter(oneIn(2))
for (let i = 0; i < someRecordActions.length; ++i) {
const action = someRecordActions[i]
const ab = oneIn(2)(action, i)
const report = await sc.createReport({
reportedBy: ab ? sc.dids.carol : sc.dids.alice,
reasonType: ab ? REASONSPAM : REASONOTHER,
subject: {
$type: 'com.atproto.repo.strongRef',
uri: action.subject.uri,
cid: action.subject.cid,
},
})
if (ab) {
await sc.resolveReports({
actionId: action.id,
reportIds: [report.id],
})
}
}
const someRepoActions = repoActions.filter(oneIn(2))
for (let i = 0; i < someRepoActions.length; ++i) {
const action = someRepoActions[i]
const ab = oneIn(2)(action, i)
const report = await sc.createReport({
reportedBy: ab ? sc.dids.carol : sc.dids.alice,
reasonType: ab ? REASONSPAM : REASONOTHER,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: action.subject.did,
},
})
if (ab) {
await sc.resolveReports({
actionId: action.id,
reportIds: [report.id],
})
}
}
})
it('gets all moderation actions.', async () => {
const result = await agent.api.com.atproto.admin.getModerationActions(
{},
{ headers: network.pds.adminAuthHeaders() },
)
expect(forSnapshot(result.data.actions)).toMatchSnapshot()
})
it('gets all moderation actions for a repo.', async () => {
const result = await agent.api.com.atproto.admin.getModerationActions(
{ subject: Object.values(sc.dids)[0] },
{ headers: network.pds.adminAuthHeaders() },
)
expect(forSnapshot(result.data.actions)).toMatchSnapshot()
})
it('gets all moderation actions for a record.', async () => {
const result = await agent.api.com.atproto.admin.getModerationActions(
{ subject: Object.values(sc.posts)[0][0].ref.uriStr },
{ headers: network.pds.adminAuthHeaders() },
)
expect(forSnapshot(result.data.actions)).toMatchSnapshot()
})
it('paginates.', async () => {
const results = (results) => results.flatMap((res) => res.actions)
const paginator = async (cursor?: string) => {
const res = await agent.api.com.atproto.admin.getModerationActions(
{ cursor, limit: 3 },
{ headers: network.pds.adminAuthHeaders() },
)
return res.data
}
const paginatedAll = await paginateAll(paginator)
paginatedAll.forEach((res) =>
expect(res.actions.length).toBeLessThanOrEqual(3),
)
const full = await agent.api.com.atproto.admin.getModerationActions(
{},
{ headers: network.pds.adminAuthHeaders() },
)
expect(full.data.actions.length).toEqual(6)
expect(results(paginatedAll)).toEqual(results([full.data]))
})
})

@ -1,100 +0,0 @@
import { SeedClient, TestNetwork } from '@atproto/dev-env'
import AtpAgent from '@atproto/api'
import {
FLAG,
TAKEDOWN,
} from '@atproto/api/src/client/types/com/atproto/admin/defs'
import {
REASONOTHER,
REASONSPAM,
} from '../../src/lexicon/types/com/atproto/moderation/defs'
import { forSnapshot } from '../_util'
import basicSeed from '../seeds/basic'
describe('admin get moderation action view', () => {
let network: TestNetwork
let agent: AtpAgent
let sc: SeedClient
beforeAll(async () => {
network = await TestNetwork.create({
dbPostgresSchema: 'views_admin_get_moderation_report',
})
agent = network.pds.getClient()
sc = network.getSeedClient()
await basicSeed(sc)
})
afterAll(async () => {
await network.close()
})
beforeAll(async () => {
const reportRepo = await sc.createReport({
reportedBy: sc.dids.bob,
reasonType: REASONSPAM,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.alice,
},
})
const reportRecord = await sc.createReport({
reportedBy: sc.dids.carol,
reasonType: REASONOTHER,
reason: 'defamation',
subject: {
$type: 'com.atproto.repo.strongRef',
uri: sc.posts[sc.dids.alice][0].ref.uriStr,
cid: sc.posts[sc.dids.alice][0].ref.cidStr,
},
})
const flagRepo = await sc.takeModerationAction({
action: FLAG,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.alice,
},
})
const takedownRecord = await sc.takeModerationAction({
action: TAKEDOWN,
subject: {
$type: 'com.atproto.repo.strongRef',
uri: sc.posts[sc.dids.alice][0].ref.uriStr,
cid: sc.posts[sc.dids.alice][0].ref.cidStr,
},
})
await sc.resolveReports({
actionId: flagRepo.id,
reportIds: [reportRepo.id, reportRecord.id],
})
await sc.resolveReports({
actionId: takedownRecord.id,
reportIds: [reportRecord.id],
})
await sc.reverseModerationAction({ id: flagRepo.id })
})
it('gets moderation report for a repo.', async () => {
const result = await agent.api.com.atproto.admin.getModerationReport(
{ id: 1 },
{ headers: network.pds.adminAuthHeaders() },
)
expect(forSnapshot(result.data)).toMatchSnapshot()
})
it('gets moderation report for a record.', async () => {
const result = await agent.api.com.atproto.admin.getModerationReport(
{ id: 2 },
{ headers: network.pds.adminAuthHeaders() },
)
expect(forSnapshot(result.data)).toMatchSnapshot()
})
it('fails when moderation report does not exist.', async () => {
const promise = agent.api.com.atproto.admin.getModerationReport(
{ id: 100 },
{ headers: network.pds.adminAuthHeaders() },
)
await expect(promise).rejects.toThrow('Report not found')
})
})

@ -1,332 +0,0 @@
import { SeedClient, TestNetwork } from '@atproto/dev-env'
import AtpAgent from '@atproto/api'
import {
ACKNOWLEDGE,
FLAG,
TAKEDOWN,
} from '@atproto/api/src/client/types/com/atproto/admin/defs'
import {
REASONOTHER,
REASONSPAM,
} from '../../src/lexicon/types/com/atproto/moderation/defs'
import { forSnapshot, paginateAll } from '../_util'
import basicSeed from '../seeds/basic'
describe('admin get moderation reports view', () => {
let network: TestNetwork
let agent: AtpAgent
let sc: SeedClient
beforeAll(async () => {
network = await TestNetwork.create({
dbPostgresSchema: 'views_admin_get_moderation_reports',
})
agent = network.pds.getClient()
sc = network.getSeedClient()
await basicSeed(sc)
})
afterAll(async () => {
await network.close()
})
beforeAll(async () => {
const oneIn = (n) => (_, i) => i % n === 0
const getAction = (i) => [FLAG, ACKNOWLEDGE, TAKEDOWN][i % 3]
const getReasonType = (i) => [REASONOTHER, REASONSPAM][i % 2]
const getReportedByDid = (i) => [sc.dids.alice, sc.dids.carol][i % 2]
const posts = Object.values(sc.posts)
.flatMap((x) => x)
.filter(oneIn(2))
const dids = Object.values(sc.dids).filter(oneIn(2))
const recordReports: Awaited<ReturnType<typeof sc.createReport>>[] = []
for (let i = 0; i < posts.length; ++i) {
const post = posts[i]
recordReports.push(
await sc.createReport({
reasonType: getReasonType(i),
reportedBy: getReportedByDid(i),
subject: {
$type: 'com.atproto.repo.strongRef',
uri: post.ref.uriStr,
cid: post.ref.cidStr,
},
}),
)
}
const repoReports: Awaited<ReturnType<typeof sc.createReport>>[] = []
for (let i = 0; i < dids.length; ++i) {
const did = dids[i]
repoReports.push(
await sc.createReport({
reasonType: getReasonType(i),
reportedBy: getReportedByDid(i),
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did,
},
}),
)
}
for (let i = 0; i < recordReports.length; ++i) {
const report = recordReports[i]
const ab = oneIn(2)(report, i)
const action = await sc.takeModerationAction({
action: getAction(i),
subject: {
$type: 'com.atproto.repo.strongRef',
uri: report.subject.uri,
cid: report.subject.cid,
},
createdBy: `did:example:admin${i}`,
})
if (ab) {
await sc.resolveReports({
actionId: action.id,
reportIds: [report.id],
})
} else {
await sc.reverseModerationAction({
id: action.id,
})
}
}
for (let i = 0; i < repoReports.length; ++i) {
const report = repoReports[i]
const ab = oneIn(2)(report, i)
const action = await sc.takeModerationAction({
action: getAction(i),
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: report.subject.did,
},
})
if (ab) {
await sc.resolveReports({
actionId: action.id,
reportIds: [report.id],
})
} else {
await sc.reverseModerationAction({
id: action.id,
})
}
}
})
it('ignores subjects when specified.', async () => {
// Get all reports and then make another request with a filter to ignore some subject dids
// and assert that the reports for those subject dids are ignored in the result set
const getDids = (reportsResponse) =>
reportsResponse.data.reports
.map((report) => report.subject.did)
// Not all reports contain a did so we're discarding the undefined values in the mapped array
.filter(Boolean)
const allReports = await agent.api.com.atproto.admin.getModerationReports(
{},
{ headers: network.pds.adminAuthHeaders() },
)
const ignoreSubjects = getDids(allReports).slice(0, 2)
const filteredReportsByDid =
await agent.api.com.atproto.admin.getModerationReports(
{ ignoreSubjects },
{ headers: network.pds.adminAuthHeaders() },
)
// Validate that when ignored by DID, all reports for that DID is ignored
getDids(filteredReportsByDid).forEach((resultDid) =>
expect(ignoreSubjects).not.toContain(resultDid),
)
const ignoredAtUriSubjects: string[] = [
`${
allReports.data.reports.find(({ subject }) => !!subject.uri)?.subject
?.uri
}`,
]
const filteredReportsByAtUri =
await agent.api.com.atproto.admin.getModerationReports(
{
ignoreSubjects: ignoredAtUriSubjects,
},
{ headers: network.pds.adminAuthHeaders() },
)
// Validate that when ignored by at uri, only the reports for that at uri is ignored
expect(filteredReportsByAtUri.data.reports.length).toEqual(
allReports.data.reports.length - 1,
)
expect(
filteredReportsByAtUri.data.reports
.map(({ subject }) => subject.uri)
.filter(Boolean),
).not.toContain(ignoredAtUriSubjects[0])
})
it('gets all moderation reports.', async () => {
const result = await agent.api.com.atproto.admin.getModerationReports(
{},
{ headers: network.pds.adminAuthHeaders() },
)
expect(forSnapshot(result.data.reports)).toMatchSnapshot()
})
it('gets all moderation reports for a repo.', async () => {
const result = await agent.api.com.atproto.admin.getModerationReports(
{ subject: Object.values(sc.dids)[0] },
{ headers: network.pds.adminAuthHeaders() },
)
expect(forSnapshot(result.data.reports)).toMatchSnapshot()
})
it('gets all moderation reports for a record.', async () => {
const result = await agent.api.com.atproto.admin.getModerationReports(
{ subject: Object.values(sc.posts)[0][0].ref.uriStr },
{ headers: network.pds.adminAuthHeaders() },
)
expect(forSnapshot(result.data.reports)).toMatchSnapshot()
})
it('gets all resolved/unresolved moderation reports.', async () => {
const resolved = await agent.api.com.atproto.admin.getModerationReports(
{ resolved: true },
{ headers: network.pds.adminAuthHeaders() },
)
expect(forSnapshot(resolved.data.reports)).toMatchSnapshot()
const unresolved = await agent.api.com.atproto.admin.getModerationReports(
{ resolved: false },
{ headers: network.pds.adminAuthHeaders() },
)
expect(forSnapshot(unresolved.data.reports)).toMatchSnapshot()
})
it('allows reverting the order of reports.', async () => {
const [
{
data: { reports: reverseList },
},
{
data: { reports: defaultList },
},
] = await Promise.all([
agent.api.com.atproto.admin.getModerationReports(
{ reverse: true },
{ headers: network.pds.adminAuthHeaders() },
),
agent.api.com.atproto.admin.getModerationReports(
{},
{ headers: network.pds.adminAuthHeaders() },
),
])
expect(defaultList[0].id).toEqual(reverseList[reverseList.length - 1].id)
expect(defaultList[defaultList.length - 1].id).toEqual(reverseList[0].id)
})
it('gets all moderation reports by active resolution action type.', async () => {
const reportsWithTakedown =
await agent.api.com.atproto.admin.getModerationReports(
{ actionType: TAKEDOWN },
{ headers: network.pds.adminAuthHeaders() },
)
expect(forSnapshot(reportsWithTakedown.data.reports)).toMatchSnapshot()
})
it('gets all moderation reports actioned by a certain moderator.', async () => {
const adminDidOne = 'did:example:admin0'
const adminDidTwo = 'did:example:admin2'
const [actionedByAdminOne, actionedByAdminTwo] = await Promise.all([
agent.api.com.atproto.admin.getModerationReports(
{ actionedBy: adminDidOne },
{ headers: network.pds.adminAuthHeaders() },
),
agent.api.com.atproto.admin.getModerationReports(
{ actionedBy: adminDidTwo },
{ headers: network.pds.adminAuthHeaders() },
),
])
const [fullReportOne, fullReportTwo] = await Promise.all([
agent.api.com.atproto.admin.getModerationReport(
{ id: actionedByAdminOne.data.reports[0].id },
{ headers: network.pds.adminAuthHeaders() },
),
agent.api.com.atproto.admin.getModerationReport(
{ id: actionedByAdminTwo.data.reports[0].id },
{ headers: network.pds.adminAuthHeaders() },
),
])
expect(forSnapshot(actionedByAdminOne.data.reports)).toMatchSnapshot()
expect(fullReportOne.data.resolvedByActions[0].createdBy).toEqual(
adminDidOne,
)
expect(forSnapshot(actionedByAdminTwo.data.reports)).toMatchSnapshot()
expect(fullReportTwo.data.resolvedByActions[0].createdBy).toEqual(
adminDidTwo,
)
})
it('paginates.', async () => {
const results = (results) => results.flatMap((res) => res.reports)
const paginator = async (cursor?: string) => {
const res = await agent.api.com.atproto.admin.getModerationReports(
{ cursor, limit: 3 },
{ headers: network.pds.adminAuthHeaders() },
)
return res.data
}
const paginatedAll = await paginateAll(paginator)
paginatedAll.forEach((res) =>
expect(res.reports.length).toBeLessThanOrEqual(3),
)
const full = await agent.api.com.atproto.admin.getModerationReports(
{},
{ headers: network.pds.adminAuthHeaders() },
)
expect(full.data.reports.length).toEqual(6)
expect(results(paginatedAll)).toEqual(results([full.data]))
})
it('paginates reverted list of reports.', async () => {
const paginator =
(reverse = false) =>
async (cursor?: string) => {
const res = await agent.api.com.atproto.admin.getModerationReports(
{ cursor, limit: 3, reverse },
{ headers: network.pds.adminAuthHeaders() },
)
return res.data
}
const [reverseResponse, defaultResponse] = await Promise.all([
paginateAll(paginator(true)),
paginateAll(paginator()),
])
const reverseList = reverseResponse.flatMap((res) => res.reports)
const defaultList = defaultResponse.flatMap((res) => res.reports)
expect(defaultList[0].id).toEqual(reverseList[reverseList.length - 1].id)
expect(defaultList[defaultList.length - 1].id).toEqual(reverseList[0].id)
})
it('filters reports by reporter DID.', async () => {
const result = await agent.api.com.atproto.admin.getModerationReports(
{ reporters: [sc.dids.alice] },
{ headers: network.pds.adminAuthHeaders() },
)
const reporterDidsFromReports = [
...new Set(result.data.reports.map(({ reportedBy }) => reportedBy)),
]
expect(reporterDidsFromReports.length).toEqual(1)
expect(reporterDidsFromReports[0]).toEqual(sc.dids.alice)
})
})

@ -1,10 +1,6 @@
import { SeedClient, TestNetwork } from '@atproto/dev-env'
import AtpAgent from '@atproto/api'
import { AtUri } from '@atproto/syntax'
import {
ACKNOWLEDGE,
TAKEDOWN,
} from '@atproto/api/src/client/types/com/atproto/admin/defs'
import {
REASONOTHER,
REASONSPAM,
@ -24,6 +20,7 @@ describe('admin get record view', () => {
agent = network.pds.getClient()
sc = network.getSeedClient()
await basicSeed(sc)
await network.processAll()
})
afterAll(async () => {
@ -31,8 +28,8 @@ describe('admin get record view', () => {
})
beforeAll(async () => {
const acknowledge = await sc.takeModerationAction({
action: ACKNOWLEDGE,
await sc.emitModerationEvent({
event: { $type: 'com.atproto.admin.defs#modEventFlag' },
subject: {
$type: 'com.atproto.repo.strongRef',
uri: sc.posts[sc.dids.alice][0].ref.uriStr,
@ -58,9 +55,8 @@ describe('admin get record view', () => {
cid: sc.posts[sc.dids.alice][0].ref.cidStr,
},
})
await sc.reverseModerationAction({ id: acknowledge.id })
await sc.takeModerationAction({
action: TAKEDOWN,
await sc.emitModerationEvent({
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.repo.strongRef',
uri: sc.posts[sc.dids.alice][0].ref.uriStr,

@ -1,9 +1,5 @@
import { SeedClient, TestNetwork } from '@atproto/dev-env'
import AtpAgent from '@atproto/api'
import {
ACKNOWLEDGE,
TAKEDOWN,
} from '@atproto/api/src/client/types/com/atproto/admin/defs'
import {
REASONOTHER,
REASONSPAM,
@ -23,6 +19,7 @@ describe('admin get repo view', () => {
agent = network.pds.getClient()
sc = network.getSeedClient()
await basicSeed(sc)
await network.processAll()
})
afterAll(async () => {
@ -30,8 +27,8 @@ describe('admin get repo view', () => {
})
beforeAll(async () => {
const acknowledge = await sc.takeModerationAction({
action: ACKNOWLEDGE,
await sc.emitModerationEvent({
event: { $type: 'com.atproto.admin.defs#modEventAcknowledge' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.alice,
@ -54,9 +51,8 @@ describe('admin get repo view', () => {
did: sc.dids.alice,
},
})
await sc.reverseModerationAction({ id: acknowledge.id })
await sc.takeModerationAction({
action: TAKEDOWN,
await sc.emitModerationEvent({
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.alice,

@ -0,0 +1,221 @@
import { TestNetwork, SeedClient } from '@atproto/dev-env'
import AtpAgent, { ComAtprotoAdminDefs } from '@atproto/api'
import { forSnapshot } from '../_util'
import basicSeed from '../seeds/basic'
import {
REASONMISLEADING,
REASONSPAM,
} from '../../src/lexicon/types/com/atproto/moderation/defs'
describe('moderation-events', () => {
let network: TestNetwork
let agent: AtpAgent
let pdsAgent: AtpAgent
let sc: SeedClient
const emitModerationEvent = async (eventData) => {
return pdsAgent.api.com.atproto.admin.emitModerationEvent(eventData, {
encoding: 'application/json',
headers: network.bsky.adminAuthHeaders('moderator'),
})
}
const queryModerationEvents = (eventQuery) =>
agent.api.com.atproto.admin.queryModerationEvents(eventQuery, {
headers: network.bsky.adminAuthHeaders('moderator'),
})
const seedEvents = async () => {
const bobsAccount = {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.bob,
}
const alicesAccount = {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.alice,
}
const bobsPost = {
$type: 'com.atproto.repo.strongRef',
uri: sc.posts[sc.dids.bob][0].ref.uriStr,
cid: sc.posts[sc.dids.bob][0].ref.cidStr,
}
const alicesPost = {
$type: 'com.atproto.repo.strongRef',
uri: sc.posts[sc.dids.alice][0].ref.uriStr,
cid: sc.posts[sc.dids.alice][0].ref.cidStr,
}
for (let i = 0; i < 4; i++) {
await emitModerationEvent({
event: {
$type: 'com.atproto.admin.defs#modEventReport',
reportType: i % 2 ? REASONSPAM : REASONMISLEADING,
comment: 'X',
},
// Report bob's account by alice and vice versa
subject: i % 2 ? bobsAccount : alicesAccount,
createdBy: i % 2 ? sc.dids.alice : sc.dids.bob,
})
await emitModerationEvent({
event: {
$type: 'com.atproto.admin.defs#modEventReport',
reportType: REASONSPAM,
comment: 'X',
},
// Report bob's post by alice and vice versa
subject: i % 2 ? bobsPost : alicesPost,
createdBy: i % 2 ? sc.dids.alice : sc.dids.bob,
})
}
}
beforeAll(async () => {
network = await TestNetwork.create({
dbPostgresSchema: 'bsky_moderation_events',
})
agent = network.bsky.getClient()
pdsAgent = network.pds.getClient()
sc = network.getSeedClient()
await basicSeed(sc)
await network.processAll()
await seedEvents()
})
afterAll(async () => {
await network.close()
})
describe('query events', () => {
it('returns all events for record or repo', async () => {
const [bobsEvents, alicesPostEvents] = await Promise.all([
queryModerationEvents({
subject: sc.dids.bob,
}),
queryModerationEvents({
subject: sc.posts[sc.dids.alice][0].ref.uriStr,
}),
])
expect(forSnapshot(bobsEvents.data.events)).toMatchSnapshot()
expect(forSnapshot(alicesPostEvents.data.events)).toMatchSnapshot()
})
it('filters events by types', async () => {
const alicesAccount = {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.alice,
}
await Promise.all([
emitModerationEvent({
event: {
$type: 'com.atproto.admin.defs#modEventComment',
comment: 'X',
},
subject: alicesAccount,
createdBy: 'did:plc:moderator',
}),
emitModerationEvent({
event: {
$type: 'com.atproto.admin.defs#modEventEscalate',
comment: 'X',
},
subject: alicesAccount,
createdBy: 'did:plc:moderator',
}),
])
const [allEvents, reportEvents] = await Promise.all([
queryModerationEvents({
subject: sc.dids.alice,
}),
queryModerationEvents({
subject: sc.dids.alice,
types: ['com.atproto.admin.defs#modEventReport'],
}),
])
expect(allEvents.data.events.length).toBeGreaterThan(
reportEvents.data.events.length,
)
expect(
[...new Set(reportEvents.data.events.map((e) => e.event.$type))].length,
).toEqual(1)
expect(
[...new Set(allEvents.data.events.map((e) => e.event.$type))].length,
).toEqual(3)
})
it('returns events for all content by user', async () => {
const [forAccount, forPost] = await Promise.all([
queryModerationEvents({
subject: sc.dids.bob,
includeAllUserRecords: true,
}),
queryModerationEvents({
subject: sc.posts[sc.dids.bob][0].ref.uriStr,
includeAllUserRecords: true,
}),
])
expect(forAccount.data.events.length).toEqual(forPost.data.events.length)
// Save events are returned from both requests
expect(forPost.data.events.map(({ id }) => id).sort()).toEqual(
forAccount.data.events.map(({ id }) => id).sort(),
)
})
it('returns paginated list of events with cursor', async () => {
const allEvents = await queryModerationEvents({
subject: sc.dids.bob,
includeAllUserRecords: true,
})
const getPaginatedEvents = async (
sortDirection: 'asc' | 'desc' = 'desc',
) => {
let defaultCursor: undefined | string = undefined
const events: ComAtprotoAdminDefs.ModEventView[] = []
let count = 0
do {
// get 1 event at a time and check we get all events
const { data } = await queryModerationEvents({
limit: 1,
subject: sc.dids.bob,
includeAllUserRecords: true,
cursor: defaultCursor,
sortDirection,
})
events.push(...data.events)
defaultCursor = data.cursor
count++
// The count is a circuit breaker to prevent infinite loop in case of failing test
} while (defaultCursor && count < 10)
return events
}
const defaultEvents = await getPaginatedEvents()
const reversedEvents = await getPaginatedEvents('asc')
expect(allEvents.data.events.length).toEqual(4)
expect(defaultEvents.length).toEqual(allEvents.data.events.length)
expect(reversedEvents.length).toEqual(allEvents.data.events.length)
expect(reversedEvents[0].id).toEqual(defaultEvents[3].id)
})
})
describe('get event', () => {
it('gets an event by specific id', async () => {
const { data } = await pdsAgent.api.com.atproto.admin.getModerationEvent(
{
id: 1,
},
{
headers: network.bsky.adminAuthHeaders('moderator'),
},
)
expect(forSnapshot(data)).toMatchSnapshot()
})
})
})

@ -0,0 +1,145 @@
import { TestNetwork, SeedClient } from '@atproto/dev-env'
import AtpAgent, {
ComAtprotoAdminDefs,
ComAtprotoAdminQueryModerationStatuses,
} from '@atproto/api'
import { forSnapshot } from '../_util'
import basicSeed from '../seeds/basic'
import {
REASONMISLEADING,
REASONSPAM,
} from '../../src/lexicon/types/com/atproto/moderation/defs'
describe('moderation-statuses', () => {
let network: TestNetwork
let agent: AtpAgent
let pdsAgent: AtpAgent
let sc: SeedClient
const emitModerationEvent = async (eventData) => {
return pdsAgent.api.com.atproto.admin.emitModerationEvent(eventData, {
encoding: 'application/json',
headers: network.bsky.adminAuthHeaders('moderator'),
})
}
const queryModerationStatuses = (statusQuery) =>
agent.api.com.atproto.admin.queryModerationStatuses(statusQuery, {
headers: network.bsky.adminAuthHeaders('moderator'),
})
const seedEvents = async () => {
const bobsAccount = {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.bob,
}
const carlasAccount = {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.alice,
}
const bobsPost = {
$type: 'com.atproto.repo.strongRef',
uri: sc.posts[sc.dids.bob][1].ref.uriStr,
cid: sc.posts[sc.dids.bob][1].ref.cidStr,
}
const alicesPost = {
$type: 'com.atproto.repo.strongRef',
uri: sc.posts[sc.dids.alice][1].ref.uriStr,
cid: sc.posts[sc.dids.alice][1].ref.cidStr,
}
for (let i = 0; i < 4; i++) {
await emitModerationEvent({
event: {
$type: 'com.atproto.admin.defs#modEventReport',
reportType: i % 2 ? REASONSPAM : REASONMISLEADING,
comment: 'X',
},
// Report bob's account by alice and vice versa
subject: i % 2 ? bobsAccount : carlasAccount,
createdBy: i % 2 ? sc.dids.alice : sc.dids.bob,
})
await emitModerationEvent({
event: {
$type: 'com.atproto.admin.defs#modEventReport',
reportType: REASONSPAM,
comment: 'X',
},
// Report bob's post by alice and vice versa
subject: i % 2 ? bobsPost : alicesPost,
createdBy: i % 2 ? sc.dids.alice : sc.dids.bob,
})
}
}
beforeAll(async () => {
network = await TestNetwork.create({
dbPostgresSchema: 'bsky_moderation_statuses',
})
agent = network.bsky.getClient()
pdsAgent = network.pds.getClient()
sc = network.getSeedClient()
await basicSeed(sc)
await network.processAll()
await seedEvents()
})
afterAll(async () => {
await network.close()
})
describe('query statuses', () => {
it('returns statuses for subjects that received moderation events', async () => {
const response = await queryModerationStatuses({})
expect(forSnapshot(response.data.subjectStatuses)).toMatchSnapshot()
})
it('returns paginated statuses', async () => {
// We know there will be exactly 4 statuses in db
const getPaginatedStatuses = async (
params: ComAtprotoAdminQueryModerationStatuses.QueryParams,
) => {
let cursor: string | undefined = ''
const statuses: ComAtprotoAdminDefs.SubjectStatusView[] = []
let count = 0
do {
const results = await queryModerationStatuses({
limit: 1,
cursor,
...params,
})
cursor = results.data.cursor
statuses.push(...results.data.subjectStatuses)
count++
// The count is just a brake-check to prevent infinite loop
} while (cursor && count < 10)
return statuses
}
const list = await getPaginatedStatuses({})
expect(list[0].id).toEqual(4)
expect(list[list.length - 1].id).toEqual(1)
await emitModerationEvent({
subject: list[1].subject,
event: {
$type: 'com.atproto.admin.defs#modEventAcknowledge',
comment: 'X',
},
createdBy: sc.dids.bob,
})
const listReviewedFirst = await getPaginatedStatuses({
sortDirection: 'desc',
sortField: 'lastReviewedAt',
})
// Verify that the item that was recently reviewed comes up first when sorted descendingly
// while the result set always contains same number of items regardless of sorting
expect(listReviewedFirst[0].id).toEqual(list[1].id)
expect(listReviewedFirst.length).toEqual(list.length)
})
})
})

File diff suppressed because it is too large Load Diff

@ -1,6 +1,5 @@
import { SeedClient, TestNetwork } from '@atproto/dev-env'
import AtpAgent from '@atproto/api'
import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs'
import { paginateAll } from '../_util'
import usersBulkSeed from '../seeds/users-bulk'
@ -25,8 +24,8 @@ describe('admin repo search view', () => {
})
beforeAll(async () => {
await sc.takeModerationAction({
action: TAKEDOWN,
await sc.emitModerationEvent({
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids['cara-wiegand69.test'],

@ -37,7 +37,8 @@ describe('fuzzy matcher', () => {
const getAllReports = () => {
return network.bsky.ctx.db
.getPrimary()
.db.selectFrom('moderation_report')
.db.selectFrom('moderation_event')
.where('action', '=', 'com.atproto.admin.defs#modEventReport')
.selectAll()
.orderBy('id', 'asc')
.execute()

@ -77,28 +77,41 @@ describe('takedowner', () => {
const post = await sc.post(alice, 'blah', undefined, [goodBlob, badBlob1])
await network.processAll()
await autoMod.processAll()
const modAction = await ctx.db.db
.selectFrom('moderation_action')
.where('subjectUri', '=', post.ref.uriStr)
.select(['action', 'id'])
.executeTakeFirst()
if (!modAction) {
const [modStatus, takedownEvent] = await Promise.all([
ctx.db.db
.selectFrom('moderation_subject_status')
.where('did', '=', alice)
.where(
'recordPath',
'=',
`${post.ref.uri.collection}/${post.ref.uri.rkey}`,
)
.select(['takendown', 'id'])
.executeTakeFirst(),
ctx.db.db
.selectFrom('moderation_event')
.where('subjectDid', '=', alice)
.where('action', '=', 'com.atproto.admin.defs#modEventTakedown')
.selectAll()
.executeTakeFirst(),
])
if (!modStatus || !takedownEvent) {
throw new Error('expected mod action')
}
expect(modAction.action).toEqual('com.atproto.admin.defs#takedown')
expect(modStatus.takendown).toEqual(true)
const record = await ctx.db.db
.selectFrom('record')
.where('uri', '=', post.ref.uriStr)
.select('takedownId')
.executeTakeFirst()
expect(record?.takedownId).toEqual(modAction.id)
expect(record?.takedownId).toBeGreaterThan(0)
const recordPds = await network.pds.ctx.db.db
.selectFrom('record')
.where('uri', '=', post.ref.uriStr)
.select('takedownRef')
.executeTakeFirst()
expect(recordPds?.takedownRef).toEqual(modAction.id.toString())
expect(recordPds?.takedownRef).toEqual(takedownEvent.id.toString())
expect(testInvalidator.invalidated.length).toBe(1)
expect(testInvalidator.invalidated[0].subject).toBe(
@ -119,28 +132,42 @@ describe('takedowner', () => {
{ headers: sc.getHeaders(alice), encoding: 'application/json' },
)
await network.processAll()
const modAction = await ctx.db.db
.selectFrom('moderation_action')
.where('subjectUri', '=', res.data.uri)
.select(['action', 'id'])
.executeTakeFirst()
if (!modAction) {
const [modStatus, takedownEvent] = await Promise.all([
ctx.db.db
.selectFrom('moderation_subject_status')
.where('did', '=', alice)
.where('recordPath', '=', `${ids.AppBskyActorProfile}/self`)
.select(['takendown', 'id'])
.executeTakeFirst(),
ctx.db.db
.selectFrom('moderation_event')
.where('subjectDid', '=', alice)
.where(
'subjectUri',
'=',
AtUri.make(alice, ids.AppBskyActorProfile, 'self').toString(),
)
.where('action', '=', 'com.atproto.admin.defs#modEventTakedown')
.selectAll()
.executeTakeFirst(),
])
if (!modStatus || !takedownEvent) {
throw new Error('expected mod action')
}
expect(modAction.action).toEqual('com.atproto.admin.defs#takedown')
expect(modStatus.takendown).toEqual(true)
const record = await ctx.db.db
.selectFrom('record')
.where('uri', '=', res.data.uri)
.select('takedownId')
.executeTakeFirst()
expect(record?.takedownId).toEqual(modAction.id)
expect(record?.takedownId).toBeGreaterThan(0)
const recordPds = await network.pds.ctx.db.db
.selectFrom('record')
.where('uri', '=', res.data.uri)
.select('takedownRef')
.executeTakeFirst()
expect(recordPds?.takedownRef).toEqual(modAction.id.toString())
expect(recordPds?.takedownRef).toEqual(takedownEvent.id.toString())
expect(testInvalidator.invalidated.length).toBe(2)
expect(testInvalidator.invalidated[1].subject).toBe(

@ -9,7 +9,6 @@ import {
import { Handler as SkeletonHandler } from '../src/lexicon/types/app/bsky/feed/getFeedSkeleton'
import { GeneratorView } from '@atproto/api/src/client/types/app/bsky/feed/defs'
import { UnknownFeedError } from '@atproto/api/src/client/types/app/bsky/feed/getFeed'
import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs'
import { ids } from '../src/lexicon/lexicons'
import {
FeedViewPost,
@ -158,9 +157,9 @@ describe('feed generation', () => {
sc.getHeaders(alice),
)
await network.processAll()
await agent.api.com.atproto.admin.takeModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
action: TAKEDOWN,
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.repo.strongRef',
uri: prime.uri,

@ -1,7 +1,6 @@
import AtpAgent from '@atproto/api'
import { wait } from '@atproto/common'
import { TestNetwork, SeedClient } from '@atproto/dev-env'
import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs'
import { forSnapshot, paginateAll, stripViewer } from '../_util'
import usersBulkSeed from '../seeds/users-bulk'
@ -240,9 +239,9 @@ describe.skip('pds actor search views', () => {
})
it('search blocks by actor takedown', async () => {
await agent.api.com.atproto.admin.takeModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
action: TAKEDOWN,
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids['cara-wiegand69.test'],

@ -2,7 +2,6 @@ import AtpAgent from '@atproto/api'
import { TestNetwork, SeedClient } from '@atproto/dev-env'
import { forSnapshot, paginateAll, stripViewerFromPost } from '../_util'
import basicSeed from '../seeds/basic'
import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs'
import { isRecord } from '../../src/lexicon/types/app/bsky/feed/post'
import { isView as isEmbedRecordWithMedia } from '../../src/lexicon/types/app/bsky/embed/recordWithMedia'
import { isView as isImageEmbed } from '../../src/lexicon/types/app/bsky/embed/images'
@ -146,22 +145,21 @@ describe('pds author feed views', () => {
expect(preBlock.feed.length).toBeGreaterThan(0)
const { data: action } =
await agent.api.com.atproto.admin.takeModerationAction(
{
action: TAKEDOWN,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: alice,
},
createdBy: 'did:example:admin',
reason: 'Y',
await agent.api.com.atproto.admin.emitModerationEvent(
{
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: alice,
},
{
encoding: 'application/json',
headers: network.pds.adminAuthHeaders(),
},
)
createdBy: 'did:example:admin',
reason: 'Y',
},
{
encoding: 'application/json',
headers: network.pds.adminAuthHeaders(),
},
)
const attempt = agent.api.app.bsky.feed.getAuthorFeed(
{ actor: alice },
@ -170,9 +168,13 @@ describe('pds author feed views', () => {
await expect(attempt).rejects.toThrow('Profile not found')
// Cleanup
await agent.api.com.atproto.admin.reverseModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
id: action.id,
event: { $type: 'com.atproto.admin.defs#modEventReverseTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: alice,
},
createdBy: 'did:example:admin',
reason: 'Y',
},
@ -193,23 +195,22 @@ describe('pds author feed views', () => {
const post = preBlock.feed[0].post
const { data: action } =
await agent.api.com.atproto.admin.takeModerationAction(
{
action: TAKEDOWN,
subject: {
$type: 'com.atproto.repo.strongRef',
uri: post.uri,
cid: post.cid,
},
createdBy: 'did:example:admin',
reason: 'Y',
await agent.api.com.atproto.admin.emitModerationEvent(
{
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.repo.strongRef',
uri: post.uri,
cid: post.cid,
},
{
encoding: 'application/json',
headers: network.pds.adminAuthHeaders(),
},
)
createdBy: 'did:example:admin',
reason: 'Y',
},
{
encoding: 'application/json',
headers: network.pds.adminAuthHeaders(),
},
)
const { data: postBlock } = await agent.api.app.bsky.feed.getAuthorFeed(
{ actor: alice },
@ -220,9 +221,14 @@ describe('pds author feed views', () => {
expect(postBlock.feed.map((item) => item.post.uri)).not.toContain(post.uri)
// Cleanup
await agent.api.com.atproto.admin.reverseModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
id: action.id,
event: { $type: 'com.atproto.admin.defs#modEventReverseTakedown' },
subject: {
$type: 'com.atproto.repo.strongRef',
uri: post.uri,
cid: post.cid,
},
createdBy: 'did:example:admin',
reason: 'Y',
},

@ -2,7 +2,6 @@ import AtpAgent from '@atproto/api'
import { TestNetwork, SeedClient } from '@atproto/dev-env'
import { forSnapshot, paginateAll, stripViewer } from '../_util'
import followsSeed from '../seeds/follows'
import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs'
describe('pds follow views', () => {
let agent: AtpAgent
@ -121,22 +120,21 @@ describe('pds follow views', () => {
})
it('blocks followers by actor takedown', async () => {
const { data: modAction } =
await agent.api.com.atproto.admin.takeModerationAction(
{
action: TAKEDOWN,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.dan,
},
createdBy: 'did:example:admin',
reason: 'Y',
await agent.api.com.atproto.admin.emitModerationEvent(
{
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.dan,
},
{
encoding: 'application/json',
headers: network.pds.adminAuthHeaders(),
},
)
createdBy: 'did:example:admin',
reason: 'Y',
},
{
encoding: 'application/json',
headers: network.pds.adminAuthHeaders(),
},
)
const aliceFollowers = await agent.api.app.bsky.graph.getFollowers(
{ actor: sc.dids.alice },
@ -147,9 +145,13 @@ describe('pds follow views', () => {
sc.dids.dan,
)
await agent.api.com.atproto.admin.reverseModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
id: modAction.id,
event: { $type: 'com.atproto.admin.defs#modEventReverseTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.dan,
},
createdBy: 'did:example:admin',
reason: 'Y',
},
@ -250,22 +252,21 @@ describe('pds follow views', () => {
})
it('blocks follows by actor takedown', async () => {
const { data: modAction } =
await agent.api.com.atproto.admin.takeModerationAction(
{
action: TAKEDOWN,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.dan,
},
createdBy: 'did:example:admin',
reason: 'Y',
await agent.api.com.atproto.admin.emitModerationEvent(
{
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.dan,
},
{
encoding: 'application/json',
headers: network.pds.adminAuthHeaders(),
},
)
createdBy: 'did:example:admin',
reason: 'Y',
},
{
encoding: 'application/json',
headers: network.pds.adminAuthHeaders(),
},
)
const aliceFollows = await agent.api.app.bsky.graph.getFollows(
{ actor: sc.dids.alice },
@ -276,9 +277,13 @@ describe('pds follow views', () => {
sc.dids.dan,
)
await agent.api.com.atproto.admin.reverseModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
id: modAction.id,
event: { $type: 'com.atproto.admin.defs#modEventReverseTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.dan,
},
createdBy: 'did:example:admin',
reason: 'Y',
},

@ -2,7 +2,6 @@ import AtpAgent from '@atproto/api'
import { TestNetwork, SeedClient, RecordRef } from '@atproto/dev-env'
import { forSnapshot, paginateAll, stripViewerFromPost } from '../_util'
import basicSeed from '../seeds/basic'
import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs'
describe('list feed views', () => {
let network: TestNetwork
@ -113,9 +112,9 @@ describe('list feed views', () => {
})
it('blocks posts by actor takedown', async () => {
const actionRes = await agent.api.com.atproto.admin.takeModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
action: TAKEDOWN,
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: bob,
@ -136,9 +135,13 @@ describe('list feed views', () => {
expect(hasBob).toBe(false)
// Cleanup
await agent.api.com.atproto.admin.reverseModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
id: actionRes.data.id,
event: { $type: 'com.atproto.admin.defs#modEventReverseTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: bob,
},
createdBy: 'did:example:admin',
reason: 'Y',
},
@ -151,9 +154,9 @@ describe('list feed views', () => {
it('blocks posts by record takedown.', async () => {
const postRef = sc.replies[bob][0].ref // Post and reply parent
const actionRes = await agent.api.com.atproto.admin.takeModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
action: TAKEDOWN,
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.repo.strongRef',
uri: postRef.uriStr,
@ -177,9 +180,14 @@ describe('list feed views', () => {
expect(hasPost).toBe(false)
// Cleanup
await agent.api.com.atproto.admin.reverseModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
id: actionRes.data.id,
event: { $type: 'com.atproto.admin.defs#modEventReverseTakedown' },
subject: {
$type: 'com.atproto.repo.strongRef',
uri: postRef.uriStr,
cid: postRef.cidStr,
},
createdBy: 'did:example:admin',
reason: 'Y',
},

@ -1,6 +1,5 @@
import AtpAgent from '@atproto/api'
import { TestNetwork, SeedClient } from '@atproto/dev-env'
import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs'
import { forSnapshot, paginateAll } from '../_util'
import basicSeed from '../seeds/basic'
import { Notification } from '../../src/lexicon/types/app/bsky/notification/listNotifications'
@ -61,7 +60,7 @@ describe('notification views', () => {
{ headers: await network.serviceHeaders(sc.dids.bob) },
)
expect(notifCountBob.data.count).toBe(4)
expect(notifCountBob.data.count).toBeGreaterThanOrEqual(3)
})
it('generates notifications for all reply ancestors', async () => {
@ -89,7 +88,7 @@ describe('notification views', () => {
{ headers: await network.serviceHeaders(sc.dids.bob) },
)
expect(notifCountBob.data.count).toBe(5)
expect(notifCountBob.data.count).toBeGreaterThanOrEqual(4)
})
it('does not give notifs for a deleted subject', async () => {
@ -233,11 +232,11 @@ describe('notification views', () => {
it('fetches notifications omitting mentions and replies for taken-down posts', async () => {
const postRef1 = sc.replies[sc.dids.carol][0].ref // Reply
const postRef2 = sc.posts[sc.dids.dan][1].ref // Mention
const actionResults = await Promise.all(
await Promise.all(
[postRef1, postRef2].map((postRef) =>
agent.api.com.atproto.admin.takeModerationAction(
agent.api.com.atproto.admin.emitModerationEvent(
{
action: TAKEDOWN,
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.repo.strongRef',
uri: postRef.uriStr,
@ -270,10 +269,15 @@ describe('notification views', () => {
// Cleanup
await Promise.all(
actionResults.map((result) =>
agent.api.com.atproto.admin.reverseModerationAction(
[postRef1, postRef2].map((postRef) =>
agent.api.com.atproto.admin.emitModerationEvent(
{
id: result.data.id,
event: { $type: 'com.atproto.admin.defs#modEventReverseTakedown' },
subject: {
$type: 'com.atproto.repo.strongRef',
uri: postRef.uriStr,
cid: postRef.cidStr,
},
createdBy: 'did:example:admin',
reason: 'Y',
},

@ -1,7 +1,6 @@
import fs from 'fs/promises'
import AtpAgent from '@atproto/api'
import { TestNetwork, SeedClient } from '@atproto/dev-env'
import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs'
import { forSnapshot, stripViewer } from '../_util'
import { ids } from '../../src/lexicon/lexicons'
import basicSeed from '../seeds/basic'
@ -186,22 +185,21 @@ describe('pds profile views', () => {
})
it('blocked by actor takedown', async () => {
const { data: action } =
await agent.api.com.atproto.admin.takeModerationAction(
{
action: TAKEDOWN,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: alice,
},
createdBy: 'did:example:admin',
reason: 'Y',
await agent.api.com.atproto.admin.emitModerationEvent(
{
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: alice,
},
{
encoding: 'application/json',
headers: network.pds.adminAuthHeaders(),
},
)
createdBy: 'did:example:admin',
reason: 'Y',
},
{
encoding: 'application/json',
headers: network.pds.adminAuthHeaders(),
},
)
const promise = agent.api.app.bsky.actor.getProfile(
{ actor: alice },
{ headers: await network.serviceHeaders(bob) },
@ -210,9 +208,13 @@ describe('pds profile views', () => {
await expect(promise).rejects.toThrow('Account has been taken down')
// Cleanup
await agent.api.com.atproto.admin.reverseModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
id: action.id,
event: { $type: 'com.atproto.admin.defs#modEventReverseTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: alice,
},
createdBy: 'did:example:admin',
reason: 'Y',
},

@ -1,6 +1,5 @@
import AtpAgent, { AppBskyFeedGetPostThread } from '@atproto/api'
import { TestNetwork, SeedClient } from '@atproto/dev-env'
import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs'
import { forSnapshot, stripViewerFromThread } from '../_util'
import basicSeed from '../seeds/basic'
import assert from 'assert'
@ -167,9 +166,9 @@ describe('pds thread views', () => {
describe('takedown', () => {
it('blocks post by actor', async () => {
const { data: modAction } =
await agent.api.com.atproto.admin.takeModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
action: TAKEDOWN,
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: alice,
@ -194,9 +193,13 @@ describe('pds thread views', () => {
)
// Cleanup
await agent.api.com.atproto.admin.reverseModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
id: modAction.id,
event: { $type: 'com.atproto.admin.defs#modEventReverseTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: alice,
},
createdBy: 'did:example:admin',
reason: 'Y',
},
@ -209,9 +212,9 @@ describe('pds thread views', () => {
it('blocks replies by actor', async () => {
const { data: modAction } =
await agent.api.com.atproto.admin.takeModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
action: TAKEDOWN,
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: carol,
@ -234,9 +237,13 @@ describe('pds thread views', () => {
expect(forSnapshot(thread.data.thread)).toMatchSnapshot()
// Cleanup
await agent.api.com.atproto.admin.reverseModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
id: modAction.id,
event: { $type: 'com.atproto.admin.defs#modEventReverseTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: carol,
},
createdBy: 'did:example:admin',
reason: 'Y',
},
@ -249,9 +256,9 @@ describe('pds thread views', () => {
it('blocks ancestors by actor', async () => {
const { data: modAction } =
await agent.api.com.atproto.admin.takeModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
action: TAKEDOWN,
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: bob,
@ -274,9 +281,13 @@ describe('pds thread views', () => {
expect(forSnapshot(thread.data.thread)).toMatchSnapshot()
// Cleanup
await agent.api.com.atproto.admin.reverseModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
id: modAction.id,
event: { $type: 'com.atproto.admin.defs#modEventReverseTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: bob,
},
createdBy: 'did:example:admin',
reason: 'Y',
},
@ -290,9 +301,9 @@ describe('pds thread views', () => {
it('blocks post by record', async () => {
const postRef = sc.posts[alice][1].ref
const { data: modAction } =
await agent.api.com.atproto.admin.takeModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
action: TAKEDOWN,
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.repo.strongRef',
uri: postRef.uriStr,
@ -317,9 +328,14 @@ describe('pds thread views', () => {
)
// Cleanup
await agent.api.com.atproto.admin.reverseModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
id: modAction.id,
event: { $type: 'com.atproto.admin.defs#modEventReverseTakedown' },
subject: {
$type: 'com.atproto.repo.strongRef',
uri: postRef.uriStr,
cid: postRef.cidStr,
},
createdBy: 'did:example:admin',
reason: 'Y',
},
@ -339,9 +355,9 @@ describe('pds thread views', () => {
const parent = threadPreTakedown.data.thread.parent?.['post']
const { data: modAction } =
await agent.api.com.atproto.admin.takeModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
action: TAKEDOWN,
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.repo.strongRef',
uri: parent.uri,
@ -365,9 +381,14 @@ describe('pds thread views', () => {
expect(forSnapshot(thread.data.thread)).toMatchSnapshot()
// Cleanup
await agent.api.com.atproto.admin.reverseModerationAction(
await agent.api.com.atproto.admin.emitModerationEvent(
{
id: modAction.id,
event: { $type: 'com.atproto.admin.defs#modEventReverseTakedown' },
subject: {
$type: 'com.atproto.repo.strongRef',
uri: parent.uri,
cid: parent.cid,
},
createdBy: 'did:example:admin',
reason: 'Y',
},
@ -388,9 +409,9 @@ describe('pds thread views', () => {
const actionResults = await Promise.all(
[post1, post2].map((post) =>
agent.api.com.atproto.admin.takeModerationAction(
agent.api.com.atproto.admin.emitModerationEvent(
{
action: TAKEDOWN,
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.repo.strongRef',
uri: post.uri,
@ -417,10 +438,17 @@ describe('pds thread views', () => {
// Cleanup
await Promise.all(
actionResults.map((result) =>
agent.api.com.atproto.admin.reverseModerationAction(
[post1, post2].map((post) =>
agent.api.com.atproto.admin.emitModerationEvent(
{
id: result.data.id,
event: {
$type: 'com.atproto.admin.defs#modEventReverseTakedown',
},
subject: {
$type: 'com.atproto.repo.strongRef',
uri: post.uri,
cid: post.cid,
},
createdBy: 'did:example:admin',
reason: 'Y',
},

@ -1,7 +1,6 @@
import assert from 'assert'
import AtpAgent from '@atproto/api'
import { TestNetwork, SeedClient } from '@atproto/dev-env'
import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs'
import { forSnapshot, getOriginator, paginateAll } from '../_util'
import basicSeed from '../seeds/basic'
import { FeedAlgorithm } from '../../src/api/app/bsky/util/feed'
@ -182,11 +181,11 @@ describe('timeline views', () => {
})
it('blocks posts, reposts, replies by actor takedown', async () => {
const actionResults = await Promise.all(
await Promise.all(
[bob, carol].map((did) =>
agent.api.com.atproto.admin.takeModerationAction(
agent.api.com.atproto.admin.emitModerationEvent(
{
action: TAKEDOWN,
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did,
@ -211,10 +210,14 @@ describe('timeline views', () => {
// Cleanup
await Promise.all(
actionResults.map((result) =>
agent.api.com.atproto.admin.reverseModerationAction(
[bob, carol].map((did) =>
agent.api.com.atproto.admin.emitModerationEvent(
{
id: result.data.id,
event: { $type: 'com.atproto.admin.defs#modEventReverseTakedown' },
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did,
},
createdBy: 'did:example:admin',
reason: 'Y',
},
@ -230,11 +233,11 @@ describe('timeline views', () => {
it('blocks posts, reposts, replies by record takedown.', async () => {
const postRef1 = sc.posts[dan][1].ref // Repost
const postRef2 = sc.replies[bob][0].ref // Post and reply parent
const actionResults = await Promise.all(
await Promise.all(
[postRef1, postRef2].map((postRef) =>
agent.api.com.atproto.admin.takeModerationAction(
agent.api.com.atproto.admin.emitModerationEvent(
{
action: TAKEDOWN,
event: { $type: 'com.atproto.admin.defs#modEventTakedown' },
subject: {
$type: 'com.atproto.repo.strongRef',
uri: postRef.uriStr,
@ -260,10 +263,15 @@ describe('timeline views', () => {
// Cleanup
await Promise.all(
actionResults.map((result) =>
agent.api.com.atproto.admin.reverseModerationAction(
[postRef1, postRef2].map((postRef) =>
agent.api.com.atproto.admin.emitModerationEvent(
{
id: result.data.id,
event: { $type: 'com.atproto.admin.defs#modEventReverseTakedown' },
subject: {
$type: 'com.atproto.repo.strongRef',
uri: postRef.uriStr,
cid: postRef.cidStr,
},
createdBy: 'did:example:admin',
reason: 'Y',
},

@ -2,7 +2,7 @@ import fs from 'fs/promises'
import { CID } from 'multiformats/cid'
import AtpAgent from '@atproto/api'
import { Main as Facet } from '@atproto/api/src/client/types/app/bsky/richtext/facet'
import { InputSchema as TakeActionInput } from '@atproto/api/src/client/types/com/atproto/admin/takeModerationAction'
import { InputSchema as TakeActionInput } from '@atproto/api/src/client/types/com/atproto/admin/emitModerationEvent'
import { InputSchema as CreateReportInput } from '@atproto/api/src/client/types/com/atproto/moderation/createReport'
import { Record as PostRecord } from '@atproto/api/src/client/types/app/bsky/feed/post'
import { Record as LikeRecord } from '@atproto/api/src/client/types/app/bsky/feed/like'
@ -419,20 +419,21 @@ export class SeedClient {
delete foundList.items[subject]
}
async takeModerationAction(opts: {
action: TakeActionInput['action']
async emitModerationEvent(opts: {
event: TakeActionInput['event']
subject: TakeActionInput['subject']
reason?: string
createdBy?: string
meta?: TakeActionInput['meta']
}) {
const {
action,
event,
subject,
reason = 'X',
createdBy = 'did:example:admin',
} = opts
const result = await this.agent.api.com.atproto.admin.takeModerationAction(
{ action, subject, createdBy, reason },
const result = await this.agent.api.com.atproto.admin.emitModerationEvent(
{ event, subject, createdBy, reason },
{
encoding: 'application/json',
headers: this.adminAuthHeaders(),
@ -443,35 +444,25 @@ export class SeedClient {
async reverseModerationAction(opts: {
id: number
subject: TakeActionInput['subject']
reason?: string
createdBy?: string
}) {
const { id, reason = 'X', createdBy = 'did:example:admin' } = opts
const result =
await this.agent.api.com.atproto.admin.reverseModerationAction(
{ id, reason, createdBy },
{
encoding: 'application/json',
headers: this.adminAuthHeaders(),
const { id, subject, reason = 'X', createdBy = 'did:example:admin' } = opts
const result = await this.agent.api.com.atproto.admin.emitModerationEvent(
{
subject,
event: {
$type: 'com.atproto.admin.defs#modEventReverseTakedown',
comment: reason,
},
)
return result.data
}
async resolveReports(opts: {
actionId: number
reportIds: number[]
createdBy?: string
}) {
const { actionId, reportIds, createdBy = 'did:example:admin' } = opts
const result =
await this.agent.api.com.atproto.admin.resolveModerationReports(
{ actionId, createdBy, reportIds },
{
encoding: 'application/json',
headers: this.adminAuthHeaders(),
},
)
createdBy,
},
{
encoding: 'application/json',
headers: this.adminAuthHeaders(),
},
)
return result.data
}

@ -3,11 +3,11 @@ import AppContext from '../../../../context'
import { authPassthru } from './util'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.takeModerationAction({
server.com.atproto.admin.emitModerationEvent({
auth: ctx.authVerifier.role,
handler: async ({ req, input }) => {
const { data: result } =
await ctx.appViewAgent.com.atproto.admin.takeModerationAction(
await ctx.appViewAgent.com.atproto.admin.emitModerationEvent(
input.body,
authPassthru(req, true),
)

@ -1,20 +0,0 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { authPassthru } from './util'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getModerationAction({
auth: ctx.authVerifier.role,
handler: async ({ req, params }) => {
const { data: resultAppview } =
await ctx.appViewAgent.com.atproto.admin.getModerationAction(
params,
authPassthru(req),
)
return {
encoding: 'application/json',
body: resultAppview,
}
},
})
}

@ -3,17 +3,17 @@ import AppContext from '../../../../context'
import { authPassthru } from './util'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getModerationActions({
server.com.atproto.admin.getModerationEvent({
auth: ctx.authVerifier.role,
handler: async ({ req, params }) => {
const { data: result } =
await ctx.appViewAgent.com.atproto.admin.getModerationActions(
const { data } =
await ctx.appViewAgent.com.atproto.admin.getModerationEvent(
params,
authPassthru(req),
)
return {
encoding: 'application/json',
body: result,
body: data,
}
},
})

@ -1,18 +1,14 @@
import AppContext from '../../../../context'
import { Server } from '../../../../lexicon'
import resolveModerationReports from './resolveModerationReports'
import reverseModerationAction from './reverseModerationAction'
import takeModerationAction from './takeModerationAction'
import emitModerationEvent from './emitModerationEvent'
import updateSubjectStatus from './updateSubjectStatus'
import getSubjectStatus from './getSubjectStatus'
import getAccountInfo from './getAccountInfo'
import searchRepos from './searchRepos'
import getRecord from './getRecord'
import getRepo from './getRepo'
import getModerationAction from './getModerationAction'
import getModerationActions from './getModerationActions'
import getModerationReport from './getModerationReport'
import getModerationReports from './getModerationReports'
import getModerationEvent from './getModerationEvent'
import queryModerationEvents from './queryModerationEvents'
import enableAccountInvites from './enableAccountInvites'
import disableAccountInvites from './disableAccountInvites'
import disableInviteCodes from './disableInviteCodes'
@ -20,21 +16,19 @@ import getInviteCodes from './getInviteCodes'
import updateAccountHandle from './updateAccountHandle'
import updateAccountEmail from './updateAccountEmail'
import sendEmail from './sendEmail'
import queryModerationStatuses from './queryModerationStatuses'
export default function (server: Server, ctx: AppContext) {
resolveModerationReports(server, ctx)
reverseModerationAction(server, ctx)
takeModerationAction(server, ctx)
emitModerationEvent(server, ctx)
updateSubjectStatus(server, ctx)
getSubjectStatus(server, ctx)
getAccountInfo(server, ctx)
searchRepos(server, ctx)
getRecord(server, ctx)
getRepo(server, ctx)
getModerationAction(server, ctx)
getModerationActions(server, ctx)
getModerationReport(server, ctx)
getModerationReports(server, ctx)
getModerationEvent(server, ctx)
queryModerationEvents(server, ctx)
queryModerationStatuses(server, ctx)
enableAccountInvites(server, ctx)
disableAccountInvites(server, ctx)
disableInviteCodes(server, ctx)

@ -3,11 +3,11 @@ import AppContext from '../../../../context'
import { authPassthru } from './util'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getModerationReports({
server.com.atproto.admin.queryModerationEvents({
auth: ctx.authVerifier.role,
handler: async ({ req, params }) => {
const { data: result } =
await ctx.appViewAgent.com.atproto.admin.getModerationReports(
await ctx.appViewAgent.com.atproto.admin.queryModerationEvents(
params,
authPassthru(req),
)

@ -3,17 +3,17 @@ import AppContext from '../../../../context'
import { authPassthru } from './util'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getModerationReport({
server.com.atproto.admin.queryModerationStatuses({
auth: ctx.authVerifier.role,
handler: async ({ req, params }) => {
const { data: resultAppview } =
await ctx.appViewAgent.com.atproto.admin.getModerationReport(
const { data } =
await ctx.appViewAgent.com.atproto.admin.queryModerationStatuses(
params,
authPassthru(req),
)
return {
encoding: 'application/json',
body: resultAppview,
body: data,
}
},
})

@ -1,20 +0,0 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { authPassthru } from './util'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.resolveModerationReports({
auth: ctx.authVerifier.role,
handler: async ({ req, input }) => {
const { data: result } =
await ctx.appViewAgent.com.atproto.admin.resolveModerationReports(
input.body,
authPassthru(req, true),
)
return {
encoding: 'application/json',
body: result,
}
},
})
}

Some files were not shown because too many files have changed in this diff Show More