Support proxying moderation through to appview ()

* in-progress work on proxying moderation to appview

* tidy

* proxy reports pds to appview, misc tidy

* test proxying of moderation endpoints

* remove report action fkeys from pds for appview sync

* test appview/pds moderation synchronization

* tidy

* tidy

* fix admin proxy tests, build

* temp disable migration

* rm relative @atproto/api imports

* fix a couple more relative imports

* tidy

* reenable migration, comment contents temporarily

* fully enable migration, remove build, misc test fixes

---------

Co-authored-by: dholms <dtholmgren@gmail.com>
This commit is contained in:
devin ivy 2023-08-04 16:06:04 -04:00 committed by GitHub
parent 43bb6ae147
commit 3f3ae4cfd5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1250 additions and 38 deletions

@ -1,7 +1,7 @@
import * as common from '@atproto/common'
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { PostView } from '@atproto/api/src/client/types/app/bsky/feed/defs'
import { PostView } from '../../../../lexicon/types/app/bsky/feed/defs'
export default function (server: Server, ctx: AppContext) {
server.app.bsky.feed.getPosts({

@ -1,10 +1,24 @@
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.roleVerifier,
handler: async ({ params, auth }) => {
handler: async ({ req, params, auth }) => {
if (ctx.shouldProxyModeration()) {
// @TODO merge invite details into action subject
const { data: result } =
await ctx.appviewAgent.com.atproto.admin.getModerationAction(
params,
authPassthru(req),
)
return {
encoding: 'application/json',
body: result,
}
}
const access = auth.credentials
const { db, services } = ctx
const { id } = params

@ -1,10 +1,23 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { authPassthru } from './util'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getModerationActions({
auth: ctx.roleVerifier,
handler: async ({ params }) => {
handler: async ({ req, params }) => {
if (ctx.shouldProxyModeration()) {
const { data: result } =
await ctx.appviewAgent.com.atproto.admin.getModerationActions(
params,
authPassthru(req),
)
return {
encoding: 'application/json',
body: result,
}
}
const { db, services } = ctx
const { subject, limit = 50, cursor } = params
const moderationService = services.moderation(db)

@ -1,10 +1,23 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { authPassthru } from './util'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getModerationReport({
auth: ctx.roleVerifier,
handler: async ({ params, auth }) => {
handler: async ({ req, params, auth }) => {
if (ctx.shouldProxyModeration()) {
const { data: result } =
await ctx.appviewAgent.com.atproto.admin.getModerationReport(
params,
authPassthru(req),
)
return {
encoding: 'application/json',
body: result,
}
}
const access = auth.credentials
const { db, services } = ctx
const { id } = params

@ -1,10 +1,23 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { authPassthru } from './util'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getModerationReports({
auth: ctx.roleVerifier,
handler: async ({ params }) => {
handler: async ({ req, params }) => {
if (ctx.shouldProxyModeration()) {
const { data: result } =
await ctx.appviewAgent.com.atproto.admin.getModerationReports(
params,
authPassthru(req),
)
return {
encoding: 'application/json',
body: result,
}
}
const { db, services } = ctx
const {
subject,

@ -1,26 +1,57 @@
import { AtUri } from '@atproto/uri'
import { InvalidRequestError } from '@atproto/xrpc-server'
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { AtUri } from '@atproto/uri'
import { authPassthru, mergeRepoViewPdsDetails } from './util'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getRecord({
auth: ctx.roleVerifier,
handler: async ({ params, auth }) => {
handler: async ({ req, params, auth }) => {
const access = auth.credentials
const { db, services } = ctx
const { uri, cid } = params
const result = await services
.record(db)
.getRecord(new AtUri(uri), cid ?? null, true)
if (!result) {
const recordDetail =
result &&
(await services.moderation(db).views.recordDetail(result, {
includeEmails: access.moderator,
}))
if (ctx.shouldProxyModeration()) {
try {
const { data: recordDetailAppview } =
await ctx.appviewAgent.com.atproto.admin.getRecord(
params,
authPassthru(req),
)
if (recordDetail) {
recordDetailAppview.repo = mergeRepoViewPdsDetails(
recordDetailAppview.repo,
recordDetail.repo,
)
}
return {
encoding: 'application/json',
body: recordDetailAppview,
}
} catch (err) {
if (err && err['error'] === 'RecordNotFound') {
throw new InvalidRequestError('Record not found', 'RecordNotFound')
} else {
throw err
}
}
}
if (!recordDetail) {
throw new InvalidRequestError('Record not found', 'RecordNotFound')
}
return {
encoding: 'application/json',
body: await services.moderation(db).views.recordDetail(result, {
includeEmails: access.moderator,
}),
body: recordDetail,
}
},
})

@ -1,23 +1,54 @@
import { InvalidRequestError } from '@atproto/xrpc-server'
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { authPassthru, mergeRepoViewPdsDetails } from './util'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.getRepo({
auth: ctx.roleVerifier,
handler: async ({ params, auth }) => {
handler: async ({ req, params, auth }) => {
const access = auth.credentials
const { db, services } = ctx
const { did } = params
const result = await services.account(db).getAccount(did, true)
if (!result) {
const repoDetail =
result &&
(await services.moderation(db).views.repoDetail(result, {
includeEmails: access.moderator,
}))
if (ctx.shouldProxyModeration()) {
try {
let { data: repoDetailAppview } =
await ctx.appviewAgent.com.atproto.admin.getRepo(
params,
authPassthru(req),
)
if (repoDetail) {
repoDetailAppview = mergeRepoViewPdsDetails(
repoDetailAppview,
repoDetail,
)
}
return {
encoding: 'application/json',
body: repoDetailAppview,
}
} catch (err) {
if (err && err['error'] === 'RepoNotFound') {
throw new InvalidRequestError('Repo not found', 'RepoNotFound')
} else {
throw err
}
}
}
if (!repoDetail) {
throw new InvalidRequestError('Repo not found', 'RepoNotFound')
}
return {
encoding: 'application/json',
body: await services.moderation(db).views.repoDetail(result, {
includeEmails: access.moderator,
}),
body: repoDetail,
}
},
})

@ -1,10 +1,23 @@
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.roleVerifier,
handler: async ({ input }) => {
handler: async ({ req, input }) => {
if (ctx.shouldProxyModeration()) {
const { data: result } =
await ctx.appviewAgent.com.atproto.admin.resolveModerationReports(
input.body,
authPassthru(req, true),
)
return {
encoding: 'application/json',
body: result,
}
}
const { db, services } = ctx
const moderationService = services.moderation(db)
const { actionId, reportIds, createdBy } = input.body

@ -3,17 +3,70 @@ import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server'
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import {
isRepoRef,
ACKNOWLEDGE,
ESCALATE,
TAKEDOWN,
} from '../../../../lexicon/types/com/atproto/admin/defs'
import { isMain as isStrongRef } from '../../../../lexicon/types/com/atproto/repo/strongRef'
import { authPassthru } from './util'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.reverseModerationAction({
auth: ctx.roleVerifier,
handler: async ({ input, auth }) => {
handler: async ({ req, input, auth }) => {
const access = auth.credentials
const { db, services } = ctx
if (ctx.shouldProxyModeration()) {
const { data: result } =
await ctx.appviewAgent.com.atproto.admin.reverseModerationAction(
input.body,
authPassthru(req, true),
)
const transact = db.transaction(async (dbTxn) => {
const moderationTxn = services.moderation(dbTxn)
const labelTxn = services.appView.label(dbTxn)
// reverse takedowns
if (result.action === TAKEDOWN && isRepoRef(result.subject)) {
await moderationTxn.reverseTakedownRepo({
did: result.subject.did,
})
}
if (result.action === TAKEDOWN && isStrongRef(result.subject)) {
await moderationTxn.reverseTakedownRecord({
uri: new AtUri(result.subject.uri),
})
}
// invert label creation & negations
const reverseLabels = (uri: string, cid: string | null) =>
labelTxn.formatAndCreate(ctx.cfg.labelerDid, uri, cid, {
create: result.negateLabelVals,
negate: result.createLabelVals,
})
if (isRepoRef(result.subject)) {
await reverseLabels(result.subject.did, null)
}
if (isStrongRef(result.subject)) {
await reverseLabels(result.subject.uri, result.subject.cid)
}
})
try {
await transact
} catch (err) {
req.log.error(
{ err, actionId: input.body.id },
'proxied moderation action reversal failed',
)
}
return {
encoding: 'application/json',
body: result,
}
}
const moderationService = services.moderation(db)
const { id, createdBy, reason } = input.body

@ -3,11 +3,26 @@ import AppContext from '../../../../context'
import { SearchKeyset } from '../../../../services/util/search'
import { sql } from 'kysely'
import { ListKeyset } from '../../../../services/account'
import { authPassthru } from './util'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.searchRepos({
auth: ctx.roleVerifier,
handler: async ({ params, auth }) => {
handler: async ({ req, params, auth }) => {
if (ctx.shouldProxyModeration()) {
// @TODO merge invite details to this list view. could also add
// support for invitedBy param, which is not supported by appview.
const { data: result } =
await ctx.appviewAgent.com.atproto.admin.searchRepos(
params,
authPassthru(req),
)
return {
encoding: 'application/json',
body: result,
}
}
const access = auth.credentials
const { db, services } = ctx
const moderationService = services.moderation(db)

@ -1,21 +1,79 @@
import { CID } from 'multiformats/cid'
import { AtUri } from '@atproto/uri'
import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server'
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import {
isRepoRef,
ACKNOWLEDGE,
ESCALATE,
TAKEDOWN,
} from '../../../../lexicon/types/com/atproto/admin/defs'
import { isMain as isStrongRef } from '../../../../lexicon/types/com/atproto/repo/strongRef'
import { getSubject, getAction } from '../moderation/util'
import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server'
import { authPassthru } from './util'
export default function (server: Server, ctx: AppContext) {
server.com.atproto.admin.takeModerationAction({
auth: ctx.roleVerifier,
handler: async ({ input, auth }) => {
handler: async ({ req, input, auth }) => {
const access = auth.credentials
const { db, services } = ctx
if (ctx.shouldProxyModeration()) {
const { data: result } =
await ctx.appviewAgent.com.atproto.admin.takeModerationAction(
input.body,
authPassthru(req, true),
)
const transact = db.transaction(async (dbTxn) => {
const authTxn = services.auth(dbTxn)
const moderationTxn = services.moderation(dbTxn)
const labelTxn = services.appView.label(dbTxn)
// perform takedowns
if (result.action === TAKEDOWN && isRepoRef(result.subject)) {
await authTxn.revokeRefreshTokensByDid(result.subject.did)
await moderationTxn.takedownRepo({
takedownId: result.id,
did: result.subject.did,
})
}
if (result.action === TAKEDOWN && isStrongRef(result.subject)) {
await moderationTxn.takedownRecord({
takedownId: result.id,
uri: new AtUri(result.subject.uri),
blobCids: result.subjectBlobCids.map((cid) => CID.parse(cid)),
})
}
// apply label creation & negations
const applyLabels = (uri: string, cid: string | null) =>
labelTxn.formatAndCreate(ctx.cfg.labelerDid, uri, cid, {
create: result.createLabelVals,
negate: result.negateLabelVals,
})
if (isRepoRef(result.subject)) {
await applyLabels(result.subject.did, null)
}
if (isStrongRef(result.subject)) {
await applyLabels(result.subject.uri, result.subject.cid)
}
})
try {
await transact
} catch (err) {
req.log.error(
{ err, actionId: result.id },
'proxied moderation action failed',
)
}
return {
encoding: 'application/json',
body: result,
}
}
const moderationService = services.moderation(db)
const {
action,

@ -0,0 +1,41 @@
import express from 'express'
import {
RepoView,
RepoViewDetail,
} from '../../../../lexicon/types/com/atproto/admin/defs'
// Output designed to passed as second arg to AtpAgent methods.
// The encoding field here is a quirk of the AtpAgent.
export function authPassthru(
req: express.Request,
withEncoding?: false,
): { headers: { authorization: string }; encoding: undefined } | undefined
export function authPassthru(
req: express.Request,
withEncoding: true,
):
| { headers: { authorization: string }; encoding: 'application/json' }
| undefined
export function authPassthru(req: express.Request, withEncoding?: boolean) {
if (req.headers.authorization) {
return {
headers: { authorization: req.headers.authorization },
encoding: withEncoding ? 'application/json' : undefined,
}
}
}
// @NOTE mutates.
// merges-in details that the pds knows about the repo.
export function mergeRepoViewPdsDetails<T extends RepoView | RepoViewDetail>(
other: T,
pds: T,
) {
other.email ??= pds.email
other.invites ??= pds.invites
other.invitedBy ??= pds.invitedBy
other.invitesDisabled ??= pds.invitesDisabled
return other
}

@ -6,9 +6,25 @@ export default function (server: Server, ctx: AppContext) {
server.com.atproto.moderation.createReport({
auth: ctx.accessVerifierCheckTakedown,
handler: async ({ input, auth }) => {
const requester = auth.credentials.did
if (ctx.shouldProxyModeration()) {
const { data: result } =
await ctx.appviewAgent.com.atproto.moderation.createReport(
input.body,
{
...(await ctx.serviceAuthHeaders(requester)),
encoding: 'application/json',
},
)
return {
encoding: 'application/json',
body: result,
}
}
const { db, services } = ctx
const { reasonType, reason, subject } = input.body
const requester = auth.credentials.did
const moderationService = services.moderation(db)

@ -4,6 +4,7 @@ import { FeedKeyset } from '../util/feed'
import { paginate } from '../../../../../db/pagination'
import AppContext from '../../../../../context'
import { FeedRow } from '../../../../services/feed'
import { authPassthru } from '../../../../../api/com/atproto/admin/util'
export default function (server: Server, ctx: AppContext) {
server.app.bsky.feed.getAuthorFeed({
@ -16,12 +17,7 @@ export default function (server: Server, ctx: AppContext) {
params,
requester
? await ctx.serviceAuthHeaders(requester)
: {
// @TODO use authPassthru() once it lands
headers: req.headers.authorization
? { authorization: req.headers.authorization }
: {},
},
: authPassthru(req),
)
return {
encoding: 'application/json',

@ -1,7 +1,7 @@
import * as common from '@atproto/common'
import { Server } from '../../../../../lexicon'
import AppContext from '../../../../../context'
import { PostView } from '@atproto/api/src/client/types/app/bsky/feed/defs'
import { PostView } from '../../../../../lexicon/types/app/bsky/feed/defs'
export default function (server: Server, ctx: AppContext) {
server.app.bsky.feed.getPosts({

@ -66,6 +66,7 @@ export interface ServerConfigValues {
dbTxLockNonce?: string
bskyAppViewEndpoint?: string
bskyAppViewModeration?: boolean
bskyAppViewDid?: string
bskyAppViewProxy: boolean
@ -211,6 +212,8 @@ export class ServerConfig {
const bskyAppViewEndpoint = nonemptyString(
process.env.BSKY_APP_VIEW_ENDPOINT,
)
const bskyAppViewModeration =
process.env.BSKY_APP_VIEW_MODERATION === 'true' ? true : false
const bskyAppViewDid = nonemptyString(process.env.BSKY_APP_VIEW_DID)
const bskyAppViewProxy =
process.env.BSKY_APP_VIEW_PROXY === 'true' ? true : false
@ -268,6 +271,7 @@ export class ServerConfig {
sequencerLeaderEnabled,
dbTxLockNonce,
bskyAppViewEndpoint,
bskyAppViewModeration,
bskyAppViewDid,
bskyAppViewProxy,
crawlersToNotify,
@ -497,6 +501,10 @@ export class ServerConfig {
return this.cfg.bskyAppViewEndpoint
}
get bskyAppViewModeration() {
return this.cfg.bskyAppViewModeration
}
get bskyAppViewDid() {
return this.cfg.bskyAppViewDid
}

@ -207,6 +207,13 @@ export class AppContext {
)
}
shouldProxyModeration(): boolean {
return (
this.cfg.bskyAppViewEndpoint !== undefined &&
this.cfg.bskyAppViewModeration === true
)
}
canProxyWrite(): boolean {
return (
this.cfg.bskyAppViewEndpoint !== undefined &&

@ -0,0 +1,56 @@
import { Kysely } from 'kysely'
import { Dialect } from '..'
export async function up(db: Kysely<unknown>, dialect: Dialect): Promise<void> {
if (dialect === 'sqlite') {
return
}
await db.schema
.alterTable('repo_root')
.dropConstraint('repo_root_takedown_id_fkey')
.execute()
await db.schema
.alterTable('record')
.dropConstraint('record_takedown_id_fkey')
.execute()
await db.schema
.alterTable('repo_blob')
.dropConstraint('repo_blob_takedown_id_fkey')
.execute()
}
export async function down(
db: Kysely<unknown>,
dialect: Dialect,
): Promise<void> {
if (dialect === 'sqlite') {
return
}
await db.schema
.alterTable('repo_root')
.addForeignKeyConstraint(
'repo_root_takedown_id_fkey',
['takedownId'],
'moderation_action',
['id'],
)
.execute()
await db.schema
.alterTable('record')
.addForeignKeyConstraint(
'record_takedown_id_fkey',
['takedownId'],
'moderation_action',
['id'],
)
.execute()
await db.schema
.alterTable('repo_blob')
.addForeignKeyConstraint(
'repo_blob_takedown_id_fkey',
['takedownId'],
'moderation_action',
['id'],
)
.execute()
}

@ -57,3 +57,4 @@ export * as _20230703T044601833Z from './20230703T044601833Z-feed-and-label-indi
export * as _20230718T170914772Z from './20230718T170914772Z-sequencer-leader-sequence'
export * as _20230727T172043676Z from './20230727T172043676Z-user-account-cursor-idx'
export * as _20230801T141349990Z from './20230801T141349990Z-invite-note'
export * as _20230801T195109532Z from './20230801T195109532Z-remove-moderation-fkeys'

@ -17,7 +17,7 @@ describe('unspecced.applyLabels', () => {
beforeAll(async () => {
server = await runTestServer({
dbPostgresSchema: 'moderation',
dbPostgresSchema: 'apply_labels',
})
close = server.close
agent = new AtpAgent({ service: server.url })

@ -26,7 +26,7 @@ describe('labeler', () => {
beforeAll(async () => {
server = await runTestServer({
dbPostgresSchema: 'views_author_feed',
dbPostgresSchema: 'labeler',
})
close = server.close
ctx = server.ctx

@ -0,0 +1,428 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`proxies admin requests creates reports of a repo. 1`] = `
Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 1,
"reasonType": "com.atproto.moderation.defs#reasonSpam",
"reportedBy": "user(0)",
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(1)",
},
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 2,
"reason": "impersonation",
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(2)",
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(1)",
},
},
]
`;
exports[`proxies admin requests fetches a list of actions. 1`] = `
Object {
"actions": Array [
Object {
"action": "com.atproto.admin.defs#acknowledge",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 3,
"reason": "Y",
"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 [],
},
Object {
"action": "com.atproto.admin.defs#flag",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 2,
"reason": "Y",
"resolvedReportIds": Array [
2,
1,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectBlobCids": Array [],
},
],
"cursor": "2",
}
`;
exports[`proxies admin requests fetches a list of reports. 1`] = `
Object {
"cursor": "2",
"reports": Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 1,
"reasonType": "com.atproto.moderation.defs#reasonSpam",
"reportedBy": "user(0)",
"resolvedByActionIds": Array [
2,
],
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(1)",
},
"subjectRepoHandle": "bob.test",
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"id": 2,
"reason": "impersonation",
"reasonType": "com.atproto.moderation.defs#reasonOther",
"reportedBy": "user(2)",
"resolvedByActionIds": Array [
2,
],
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(1)",
},
"subjectRepoHandle": "bob.test",
},
],
}
`;
exports[`proxies admin requests fetches action details. 1`] = `
Object {
"action": "com.atproto.admin.defs#acknowledge",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 3,
"reason": "Y",
"resolvedReports": Array [],
"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)",
"handle": "bob.test",
"indexedAt": "1970-01-01T00:00:00.000Z",
"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": "hi im bob label_me",
"displayName": "bobby",
},
],
},
"subjectBlobs": Array [],
}
`;
exports[`proxies admin requests fetches record details. 1`] = `
Object {
"blobCids": Array [],
"blobs": Array [],
"cid": "cids(0)",
"indexedAt": "1970-01-01T00:00:00.000Z",
"labels": Array [],
"moderation": Object {
"actions": 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 [
2,
1,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectBlobCids": Array [],
},
],
"currentAction": Object {
"action": "com.atproto.admin.defs#flag",
"id": 2,
},
"reports": Array [],
},
"repo": Object {
"did": "user(0)",
"email": "bob@test.com",
"handle": "bob.test",
"indexedAt": "1970-01-01T00:00:00.000Z",
"invitedBy": Object {
"available": 10,
"code": "invite-code",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "admin",
"disabled": false,
"forAccount": "admin",
"uses": Array [
Object {
"usedAt": "1970-01-01T00:00:00.000Z",
"usedBy": "user(1)",
},
Object {
"usedAt": "1970-01-01T00:00:00.000Z",
"usedBy": "user(0)",
},
Object {
"usedAt": "1970-01-01T00:00:00.000Z",
"usedBy": "user(2)",
},
Object {
"usedAt": "1970-01-01T00:00:00.000Z",
"usedBy": "user(3)",
},
],
},
"invitesDisabled": true,
"moderation": Object {
"currentAction": Object {
"action": "com.atproto.admin.defs#acknowledge",
"id": 3,
},
},
"relatedRecords": Array [
Object {
"$type": "app.bsky.actor.profile",
"avatar": Object {
"$type": "blob",
"mimeType": "image/jpeg",
"ref": Object {
"$link": "cids(1)",
},
"size": 3976,
},
"description": "hi im bob label_me",
"displayName": "bobby",
},
],
},
"uri": "record(0)",
"value": Object {
"$type": "app.bsky.feed.post",
"createdAt": "1970-01-01T00:00:00.000+00:00",
"text": "bobby boy here",
},
}
`;
exports[`proxies admin requests fetches repo details. 1`] = `
Object {
"did": "user(0)",
"email": "eve@test.com",
"handle": "eve.test",
"indexedAt": "1970-01-01T00:00:00.000Z",
"invitedBy": Object {
"available": 1,
"code": "invite-code",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "admin",
"disabled": false,
"forAccount": "user(1)",
"uses": Array [
Object {
"usedAt": "1970-01-01T00:00:00.000Z",
"usedBy": "user(0)",
},
],
},
"invites": Array [],
"invitesDisabled": false,
"labels": Array [],
"moderation": Object {
"actions": Array [],
"reports": Array [],
},
"relatedRecords": Array [],
}
`;
exports[`proxies admin requests fetches report details. 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": 2,
"reason": "Y",
"resolvedReportIds": Array [
2,
1,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(1)",
"uri": "record(0)",
},
"subjectBlobCids": Array [],
},
],
"subject": Object {
"$type": "com.atproto.admin.defs#repoView",
"did": "user(1)",
"handle": "bob.test",
"indexedAt": "1970-01-01T00:00:00.000Z",
"moderation": Object {
"currentAction": Object {
"action": "com.atproto.admin.defs#acknowledge",
"id": 3,
},
},
"relatedRecords": Array [
Object {
"$type": "app.bsky.actor.profile",
"avatar": Object {
"$type": "blob",
"mimeType": "image/jpeg",
"ref": Object {
"$link": "cids(0)",
},
"size": 3976,
},
"description": "hi im bob label_me",
"displayName": "bobby",
},
],
},
}
`;
exports[`proxies admin requests reverses action. 1`] = `
Object {
"action": "com.atproto.admin.defs#acknowledge",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 3,
"reason": "Y",
"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 [],
}
`;
exports[`proxies admin requests searches repos. 1`] = `
Array [
Object {
"did": "user(0)",
"handle": "alice.test",
"indexedAt": "1970-01-01T00:00:00.000Z",
"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",
},
],
},
]
`;
exports[`proxies admin requests takes actions and resolves reports 1`] = `
Object {
"action": "com.atproto.admin.defs#flag",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 2,
"reason": "Y",
"resolvedReportIds": Array [],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectBlobCids": Array [],
}
`;
exports[`proxies admin requests takes actions and resolves reports 2`] = `
Object {
"action": "com.atproto.admin.defs#acknowledge",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 3,
"reason": "Y",
"resolvedReportIds": Array [],
"subject": Object {
"$type": "com.atproto.admin.defs#repoRef",
"did": "user(0)",
},
"subjectBlobCids": Array [],
}
`;
exports[`proxies admin requests takes actions and resolves reports 3`] = `
Object {
"action": "com.atproto.admin.defs#flag",
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "did:example:admin",
"id": 2,
"reason": "Y",
"resolvedReportIds": Array [
2,
1,
],
"subject": Object {
"$type": "com.atproto.repo.strongRef",
"cid": "cids(0)",
"uri": "record(0)",
},
"subjectBlobCids": Array [],
}
`;

@ -0,0 +1,405 @@
import AtpAgent from '@atproto/api'
import { TestNetwork } from '@atproto/dev-env'
import { SeedClient } from '../seeds/client'
import basicSeed from '../seeds/basic'
import {
REASONOTHER,
REASONSPAM,
} from '@atproto/api/src/client/types/com/atproto/moderation/defs'
import { forSnapshot } from '../_util'
import {
ACKNOWLEDGE,
FLAG,
TAKEDOWN,
} from '@atproto/api/src/client/types/com/atproto/admin/defs'
import { NotFoundError } from '@atproto/api/src/client/types/app/bsky/feed/getPostThread'
describe('proxies admin requests', () => {
let network: TestNetwork
let agent: AtpAgent
let sc: SeedClient
beforeAll(async () => {
network = await TestNetwork.create({
dbPostgresSchema: 'proxy_admin',
pds: {
// @NOTE requires admin pass be the same on pds and appview, which TestNetwork is handling for us.
enableInProcessAppView: true,
bskyAppViewModeration: true,
inviteRequired: true,
},
})
agent = network.pds.getClient()
sc = new SeedClient(agent)
const { data: invite } =
await agent.api.com.atproto.server.createInviteCode(
{ useCount: 10 },
{
encoding: 'application/json',
headers: network.pds.adminAuthHeaders(),
},
)
await basicSeed(sc, invite)
await network.processAll()
})
beforeAll(async () => {
const { data: invite } =
await agent.api.com.atproto.server.createInviteCode(
{ useCount: 1, forAccount: sc.dids.alice },
{
headers: network.pds.adminAuthHeaders(),
encoding: 'application/json',
},
)
await agent.api.com.atproto.admin.disableAccountInvites(
{ account: sc.dids.bob },
{ headers: network.pds.adminAuthHeaders(), encoding: 'application/json' },
)
await sc.createAccount('eve', {
handle: 'eve.test',
email: 'eve@test.com',
password: 'password',
inviteCode: invite.code,
})
await network.processAll()
})
afterAll(async () => {
await network.close()
})
it('creates reports of a repo.', async () => {
const { data: reportA } =
await agent.api.com.atproto.moderation.createReport(
{
reasonType: REASONSPAM,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.bob,
},
},
{
headers: sc.getHeaders(sc.dids.alice),
encoding: 'application/json',
},
)
const { data: reportB } =
await agent.api.com.atproto.moderation.createReport(
{
reasonType: REASONOTHER,
reason: 'impersonation',
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.bob,
},
},
{
headers: sc.getHeaders(sc.dids.carol),
encoding: 'application/json',
},
)
expect(forSnapshot([reportA, reportB])).toMatchSnapshot()
})
it('takes actions and resolves reports', async () => {
const post = sc.posts[sc.dids.bob][1]
const { data: actionA } =
await agent.api.com.atproto.admin.takeModerationAction(
{
action: FLAG,
subject: {
$type: 'com.atproto.repo.strongRef',
uri: post.ref.uriStr,
cid: post.ref.cidStr,
},
createdBy: 'did:example:admin',
reason: 'Y',
},
{
headers: network.pds.adminAuthHeaders(),
encoding: 'application/json',
},
)
expect(forSnapshot(actionA)).toMatchSnapshot()
const { data: actionB } =
await agent.api.com.atproto.admin.takeModerationAction(
{
action: ACKNOWLEDGE,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.bob,
},
createdBy: 'did:example:admin',
reason: 'Y',
},
{
headers: network.pds.adminAuthHeaders(),
encoding: 'application/json',
},
)
expect(forSnapshot(actionB)).toMatchSnapshot()
const { data: resolved } =
await agent.api.com.atproto.admin.resolveModerationReports(
{
actionId: actionA.id,
reportIds: [1, 2],
createdBy: 'did:example:admin',
},
{
headers: network.pds.adminAuthHeaders(),
encoding: 'application/json',
},
)
expect(forSnapshot(resolved)).toMatchSnapshot()
})
it('fetches report details.', async () => {
const { data: result } =
await agent.api.com.atproto.admin.getModerationReport(
{ id: 1 },
{ headers: network.pds.adminAuthHeaders() },
)
expect(forSnapshot(result)).toMatchSnapshot()
})
it('fetches a list of reports.', async () => {
const { data: result } =
await agent.api.com.atproto.admin.getModerationReports(
{ reverse: true },
{ headers: network.pds.adminAuthHeaders() },
)
expect(forSnapshot(result)).toMatchSnapshot()
})
it('fetches repo details.', async () => {
const { data: result } = await agent.api.com.atproto.admin.getRepo(
{ did: sc.dids.eve },
{ headers: network.pds.adminAuthHeaders() },
)
expect(forSnapshot(result)).toMatchSnapshot()
})
it('fetches record details.', async () => {
const post = sc.posts[sc.dids.bob][1]
const { data: result } = await agent.api.com.atproto.admin.getRecord(
{ uri: post.ref.uriStr },
{ headers: network.pds.adminAuthHeaders() },
)
expect(forSnapshot(result)).toMatchSnapshot()
})
it('reverses action.', async () => {
const { data: result } =
await agent.api.com.atproto.admin.reverseModerationAction(
{ id: 3, createdBy: 'did:example:admin', reason: 'X' },
{
headers: network.pds.adminAuthHeaders(),
encoding: 'application/json',
},
)
expect(forSnapshot(result)).toMatchSnapshot()
})
it('fetches action details.', async () => {
const { data: result } =
await agent.api.com.atproto.admin.getModerationAction(
{ id: 3 },
{ headers: network.pds.adminAuthHeaders() },
)
expect(forSnapshot(result)).toMatchSnapshot()
})
it('fetches a list of actions.', async () => {
const { data: result } =
await agent.api.com.atproto.admin.getModerationActions(
{ subject: sc.dids.bob },
{ headers: network.pds.adminAuthHeaders() },
)
expect(forSnapshot(result)).toMatchSnapshot()
})
it('searches repos.', async () => {
const { data: result } = await agent.api.com.atproto.admin.searchRepos(
{ term: 'alice' },
{ headers: network.pds.adminAuthHeaders() },
)
expect(forSnapshot(result.repos)).toMatchSnapshot()
})
it('passes through errors.', async () => {
const tryGetReport = agent.api.com.atproto.admin.getModerationReport(
{ id: 1000 },
{ headers: network.pds.adminAuthHeaders() },
)
await expect(tryGetReport).rejects.toThrow('Report not found')
const tryGetRepo = agent.api.com.atproto.admin.getRepo(
{ did: 'did:does:not:exist' },
{ headers: network.pds.adminAuthHeaders() },
)
await expect(tryGetRepo).rejects.toThrow('Repo not found')
const tryGetRecord = agent.api.com.atproto.admin.getRecord(
{ uri: 'at://did:does:not:exist/bad.collection.name/badrkey' },
{ headers: network.pds.adminAuthHeaders() },
)
await expect(tryGetRecord).rejects.toThrow('Record not found')
})
it('takesdown and labels repos, and reverts.', async () => {
const { db, services } = network.pds.ctx
// takedown repo
const { data: action } =
await agent.api.com.atproto.admin.takeModerationAction(
{
action: TAKEDOWN,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.alice,
},
createdBy: 'did:example:admin',
reason: 'Y',
createLabelVals: ['dogs'],
negateLabelVals: ['cats'],
},
{
headers: network.pds.adminAuthHeaders(),
encoding: 'application/json',
},
)
// check profile and labels
const tryGetProfilePds = agent.api.app.bsky.actor.getProfile(
{ actor: sc.dids.alice },
{ headers: sc.getHeaders(sc.dids.carol) },
)
const tryGetProfileAppview = agent.api.app.bsky.actor.getProfile(
{ actor: sc.dids.alice },
{
headers: { ...sc.getHeaders(sc.dids.carol), 'x-appview-proxy': 'true' },
},
)
await expect(tryGetProfilePds).rejects.toThrow(
'Account has been taken down',
)
await expect(tryGetProfileAppview).rejects.toThrow(
'Account has been taken down',
)
const labelsA = await services.appView
.label(db)
.getLabels(sc.dids.alice, false, true)
expect(labelsA.map((l) => l.val)).toEqual(['dogs'])
// reverse action
await agent.api.com.atproto.admin.reverseModerationAction(
{ id: action.id, createdBy: 'did:example:admin', reason: 'X' },
{
headers: network.pds.adminAuthHeaders(),
encoding: 'application/json',
},
)
// check profile and labels
const { data: profilePds } = await agent.api.app.bsky.actor.getProfile(
{ actor: sc.dids.alice },
{ headers: sc.getHeaders(sc.dids.carol) },
)
const { data: profileAppview } = await agent.api.app.bsky.actor.getProfile(
{ actor: sc.dids.alice },
{
headers: { ...sc.getHeaders(sc.dids.carol), 'x-appview-proxy': 'true' },
},
)
expect(profilePds).toEqual(
expect.objectContaining({ did: sc.dids.alice, handle: 'alice.test' }),
)
expect(profileAppview).toEqual(
expect.objectContaining({ did: sc.dids.alice, handle: 'alice.test' }),
)
const labelsB = await services.appView
.label(db)
.getLabels(sc.dids.alice, false, true)
expect(labelsB.map((l) => l.val)).toEqual(['cats'])
})
it('takesdown and labels records, and reverts.', async () => {
const { db, services } = network.pds.ctx
const post = sc.posts[sc.dids.alice][0]
// takedown post
const { data: action } =
await agent.api.com.atproto.admin.takeModerationAction(
{
action: TAKEDOWN,
subject: {
$type: 'com.atproto.repo.strongRef',
uri: post.ref.uriStr,
cid: post.ref.cidStr,
},
createdBy: 'did:example:admin',
reason: 'Y',
createLabelVals: ['dogs'],
negateLabelVals: ['cats'],
},
{
headers: network.pds.adminAuthHeaders(),
encoding: 'application/json',
},
)
// check thread and labels
const tryGetPostPds = agent.api.app.bsky.feed.getPostThread(
{ uri: post.ref.uriStr, depth: 0 },
{ headers: sc.getHeaders(sc.dids.carol) },
)
const tryGetPostAppview = agent.api.app.bsky.feed.getPostThread(
{ uri: post.ref.uriStr, depth: 0 },
{
headers: { ...sc.getHeaders(sc.dids.carol), 'x-appview-proxy': 'true' },
},
)
await expect(tryGetPostPds).rejects.toThrow(NotFoundError)
await expect(tryGetPostAppview).rejects.toThrow(NotFoundError)
const labelsA = await services.appView
.label(db)
.getLabels(post.ref.uriStr, false, true)
expect(labelsA.map((l) => l.val)).toEqual(['dogs'])
// reverse action
await agent.api.com.atproto.admin.reverseModerationAction(
{ id: action.id, createdBy: 'did:example:admin', reason: 'X' },
{
headers: network.pds.adminAuthHeaders(),
encoding: 'application/json',
},
)
// check thread and labels
const { data: threadPds } = await agent.api.app.bsky.feed.getPostThread(
{ uri: post.ref.uriStr, depth: 0 },
{ headers: sc.getHeaders(sc.dids.carol) },
)
const { data: threadAppview } = await agent.api.app.bsky.feed.getPostThread(
{ uri: post.ref.uriStr, depth: 0 },
{
headers: { ...sc.getHeaders(sc.dids.carol), 'x-appview-proxy': 'true' },
},
)
expect(threadPds.thread.post).toEqual(
expect.objectContaining({ uri: post.ref.uriStr, cid: post.ref.cidStr }),
)
expect(threadAppview.thread.post).toEqual(
expect.objectContaining({ uri: post.ref.uriStr, cid: post.ref.cidStr }),
)
const labelsB = await services.appView
.label(db)
.getLabels(post.ref.uriStr, false, true)
expect(labelsB.map((l) => l.val)).toEqual(['cats'])
})
it('does not persist actions and reports on pds.', async () => {
const { db } = network.pds.ctx
const actions = await db.db
.selectFrom('moderation_action')
.selectAll()
.execute()
const reports = await db.db
.selectFrom('moderation_report')
.selectAll()
.execute()
expect(actions).toEqual([])
expect(reports).toEqual([])
})
})

@ -4,8 +4,8 @@ import { adminAuth } from '../_util'
import { SeedClient } from './client'
import usersSeed from './users'
export default async (sc: SeedClient) => {
await usersSeed(sc)
export default async (sc: SeedClient, invite?: { code: string }) => {
await usersSeed(sc, invite)
const alice = sc.dids.alice
const bob = sc.dids.bob

@ -1,10 +1,10 @@
import { SeedClient } from './client'
export default async (sc: SeedClient) => {
await sc.createAccount('alice', users.alice)
await sc.createAccount('bob', users.bob)
await sc.createAccount('carol', users.carol)
await sc.createAccount('dan', users.dan)
export default async (sc: SeedClient, invite?: { code: string }) => {
await sc.createAccount('alice', { ...users.alice, inviteCode: invite?.code })
await sc.createAccount('bob', { ...users.bob, inviteCode: invite?.code })
await sc.createAccount('carol', { ...users.carol, inviteCode: invite?.code })
await sc.createAccount('dan', { ...users.dan, inviteCode: invite?.code })
await sc.createProfile(
sc.dids.alice,