Skip to content
Open
11 changes: 9 additions & 2 deletions src/Controller/V2/Document/DocumentController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { ProjectPermission } from '../../..//Models/ProjectModels'
import { DIContainer } from '../../../DIContainer/DIContainer'
import { DocumentPermission } from '../../../DomainServices/DocumentService'
Expand Down Expand Up @@ -105,7 +104,15 @@ export class DocumentController extends BaseController {
projectID,
DocumentPermission.WRITE
)
return await DIContainer.sharedContainer.authorityService.receiveSteps(manuscriptID, payload)
const permittedActions = await DIContainer.sharedContainer.authorityService.getPermittedActions(
projectID,
user.id
)

return await DIContainer.sharedContainer.authorityService.receiveSteps(manuscriptID, payload, {
userId: user.id,
actions: permittedActions,
})
}

broadcastSteps(manuscriptID: string, result: History) {
Expand Down
3 changes: 3 additions & 0 deletions src/DIContainer/DIContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { ProjectService } from '../DomainServices/ProjectService'
import { RegisterationService } from '../DomainServices/RegisterationService'
import { SocketsService } from '../DomainServices/SocketsService'
import { UserService } from '../DomainServices/UserService'
import { StepAccessService } from '../DomainServices/StepAccessService'
import {
DocumentClient,
EventClient,
Expand Down Expand Up @@ -85,6 +86,7 @@ export class DIContainer {
readonly eventclient: EventClient
readonly eventManager: EventManager
readonly socketsService: SocketsService
readonly stepAccessService: StepAccessService

/**
* WARNING: internal method.
Expand Down Expand Up @@ -118,6 +120,7 @@ export class DIContainer {
this.socketsService = new SocketsService()
this.documentService = new DocumentService(this.socketsService, repository.documentClient)
this.oEmbedService = new OEmbedService()
this.stepAccessService = new StepAccessService()
}

/**
Expand Down
35 changes: 29 additions & 6 deletions src/DomainServices/AuthorityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,37 @@
* limitations under the License.
*/

import { getVersion, JSONProsemirrorNode, ManuscriptActions, schema } from '@manuscripts/transform'
import {
getVersion,
JSONProsemirrorNode,
ManuscriptActions,
AccessContext,
schema,
} from '@manuscripts/transform'
import { Prisma } from '@prisma/client'
import { JsonObject } from '@prisma/client/runtime/library'
import { Step } from 'prosemirror-transform'

import { DIContainer } from '../DIContainer/DIContainer'
import { UserRoleError, VersionMismatchError } from '../Errors'
import { StepAccessError, UserRoleError, VersionMismatchError } from '../Errors'
import { History, ModifiedStep, ReceiveSteps } from '../Models/AuthorityModels'
import { ProjectUserRole } from '../Models/ProjectModels'
import { DB } from '../Models/RepositoryModels'

export class AuthorityService {
constructor(private readonly repository: DB) {}

public async receiveSteps(documentID: string, receiveSteps: ReceiveSteps): Promise<History> {
public async receiveSteps(
documentID: string,
receiveSteps: ReceiveSteps,
accessContext: AccessContext
): Promise<History> {
const found = await this.repository.manuscriptDoc.findDocument(documentID)
const { doc, modifiedSteps } = this.applyStepsToDocument(
receiveSteps.steps,
found.doc,
receiveSteps.clientID.toString()
receiveSteps.clientID.toString(),
accessContext
)
try {
await this.repository.manuscriptDoc.updateDocumentWithVersionCheck(
Expand Down Expand Up @@ -82,7 +93,10 @@ export class AuthorityService {
return history
}

public async getPermittedActions(projectID: string, userID: string): Promise<Record<ManuscriptActions, boolean>> {
public async getPermittedActions(
projectID: string,
userID: string
): Promise<Record<ManuscriptActions, boolean>> {
const project = await DIContainer.sharedContainer.projectService.getProject(projectID)
const role = DIContainer.sharedContainer.projectService.getUserRole(project, userID)

Expand Down Expand Up @@ -123,12 +137,21 @@ export class AuthorityService {
private applyStepsToDocument(
jsonSteps: Prisma.JsonObject[],
document: Prisma.JsonValue,
clientID: string
clientID: string,
accessContext: AccessContext
) {
const steps = this.hydrateSteps(jsonSteps)
const modifiedSteps: ModifiedStep[] = []
let pmDocument = schema.nodeFromJSON(document)
for (let i = 0; i < steps.length; i++) {
const hasAccessToStep = DIContainer.sharedContainer.stepAccessService.validate(
steps[i],
pmDocument,
accessContext
)
if (!hasAccessToStep) {
throw new StepAccessError(steps[i])
}
pmDocument = steps[i].apply(pmDocument).doc || pmDocument
modifiedSteps.push({ ...jsonSteps[i], clientID })
}
Expand Down
113 changes: 113 additions & 0 deletions src/DomainServices/StepAccessService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*!
* © 2026 Atypon Systems LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
AccessContext,
getNodeAccessPolicy,
ManuscriptNode,
ExposedSlice,
} from '@manuscripts/transform'
import { AttrStep, ReplaceAroundStep, ReplaceStep, Step } from 'prosemirror-transform'

export class StepAccessService {
validate(step: Step, doc: ManuscriptNode, context: AccessContext) {
if (step instanceof ReplaceAroundStep) {
const gap = doc.slice(step.gapFrom, step.gapTo)
const slice = (
step.slice as ExposedSlice<typeof step.slice, typeof step.slice.content>
).insertAt(step.insert, gap.content)
return this.validateReplaceStep(new ReplaceStep(step.from, step.to, slice), doc, context)
}

if (step instanceof ReplaceStep) {
return this.validateReplaceStep(step, doc, context)
}

if (step instanceof AttrStep) {
return this.validateAttrStep(step, doc, context)
}
return true
}

private validateReplaceStep(step: ReplaceStep, doc: ManuscriptNode, context: AccessContext) {
if (this.isStepUpdateNodeAttr(step, doc)) {
const node = step.slice.content.firstChild!
const nodeDB = doc.slice(step.from, step.to).content.firstChild!
return !this.findDiff(nodeDB, node).find((attr) => !this.attrPolicy(nodeDB, attr, context))
}

let hasAccess = context.actions.editArticle

doc.slice(step.from, step.to).content.descendants((node) => {
const deletePolicy = getNodeAccessPolicy(node.type)?.delete
if (deletePolicy && !deletePolicy(node, context)) {
hasAccess = false
return false
}
})

step.slice.content.descendants((node) => {
const insertPolicy = getNodeAccessPolicy(node.type)?.insert
if (insertPolicy && !insertPolicy(node, context)) {
hasAccess = false
return false
}
})

return hasAccess
}

private validateAttrStep(step: AttrStep, doc: ManuscriptNode, context: AccessContext) {
const node = doc.nodeAt(step.pos)
Comment thread
mbartenev-atypon marked this conversation as resolved.
return this.attrPolicy(node, step.attr, context)
}

private isStepUpdateNodeAttr(step: ReplaceStep, doc: ManuscriptNode) {
const stepContent = step.slice.content
const sliceContent = doc.slice(step.from, step.to).content
return (
stepContent.size === sliceContent.size &&
stepContent.childCount === 1 &&
sliceContent.childCount === 1 &&
stepContent.firstChild!.content.eq(sliceContent.firstChild!.content)
)
}

private findDiff(nodeA: ManuscriptNode, nodeB: ManuscriptNode) {
const keys: string[] = []
Object.entries(nodeB.attrs).map(([key, value]) => {
if (!nodeA.hasMarkup(nodeA.type, { ...nodeA.attrs, [key]: value })) {
keys.push(key)
}
})
return keys
}

private attrPolicy(node: ManuscriptNode | null, attr: string, context: AccessContext) {
const policy = node?.type && getNodeAccessPolicy(node.type)?.attrs

if (policy) {
// we could have a policy that applied to all attribute changes
Comment thread
mbartenev-atypon marked this conversation as resolved.
if (typeof policy === 'function') {
return policy(node, context)
} else {
// apply policy per-attribute
return !!policy[attr]?.(node, context)
}
}

return context.actions.editMetadata
}
}
14 changes: 14 additions & 0 deletions src/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Prisma } from '@prisma/client'
import { StatusCodes } from 'http-status-codes'

import { InternalErrorCode } from './InternalErrorCodes'
import { Step } from 'prosemirror-transform'

/** An error-like object that has a code. Used amongst error types to describe those error types that have their own natural HTTP status code. */
export interface StatusCoded {
Expand Down Expand Up @@ -287,3 +288,16 @@ export class ForbiddenOriginError extends Error implements StatusCoded {
Object.setPrototypeOf(this, new.target.prototype)
}
}

export class StepAccessError extends Error implements StatusCoded {
readonly internalErrorCode = InternalErrorCode.StepAccessError
readonly statusCode = StatusCodes.FORBIDDEN
step: JSON
Comment thread
mbartenev-atypon marked this conversation as resolved.

constructor(step: Step) {
super(`User role is not permitted to apply step`)
this.name = 'StepAccessError'
this.step = step.toJSON()
Object.setPrototypeOf(this, new.target.prototype)
Comment thread
asouqi marked this conversation as resolved.
}
}
1 change: 1 addition & 0 deletions src/InternalErrorCodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

export enum InternalErrorCode {
SyncError = 'SG_ERR',
StepAccessError = 'STEP_ACCESS_ERR',
NumericalError = 'NUMERICAL_ERR',
NoBucketError = 'CB_BUCKET_NOT_FOUND',
InvalidBucketError = 'CB_INVALID_BUCKET',
Expand Down
115 changes: 115 additions & 0 deletions test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*!
* © 2026 Atypon Systems LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { AccessContext, schema } from '@manuscripts/transform'
import { Transform } from 'prosemirror-transform'

import { DIContainer } from '../../../../../src/DIContainer/DIContainer'
import { TEST_TIMEOUT } from '../../../../utilities/testSetup'

jest.setTimeout(TEST_TIMEOUT)

let accessContext: AccessContext
let tr: Transform

beforeEach(async () => {
;(DIContainer as any)._sharedContainer = null
await DIContainer.init()
accessContext = {
userId: 'MPUserProfile:01',
actions: {
handleSuggestion: true,
rejectOwnSuggestion: true,
handleOwnComments: true,
handleOthersComments: true,
resolveOwnComment: true,
resolveOthersComment: true,
createComment: true,
canEditFiles: true,
editArticle: true,
formatArticle: true,
editMetadata: true,
editCitationsAndRefs: true,
seeEditorToolbar: true,
seeReferencesButtons: true,
},
}
const emptyDoc = schema.nodes.doc.createAndFill()!
tr = new Transform(emptyDoc)
tr.insert(10, schema.nodeFromJSON(comment))
})

afterEach(() => {
jest.clearAllMocks()
})

const comment = {
type: 'comment',
attrs: {
id: 'MPCommentAnnotation:29D4335B',
contents: 'comment content',
target: 'MPParagraphElement:06D94BD3',
resolved: false,
userID: 'MPUserProfile:01',
originalText: '',
},
}

describe('StepAccessService', () => {
describe('validate', () => {
it('has no access to resolve other comment', () => {
accessContext.userId = 'MPUserProfile:02'
accessContext.actions.resolveOthersComment = false
tr.setNodeMarkup(10, undefined, { ...comment.attrs, resolved: true })
const hasAccessToStep = DIContainer.sharedContainer.stepAccessService.validate(
tr.steps[1],
tr.docs[1],
accessContext
)
expect(hasAccessToStep).toEqual(false)
})
it('has no access to resolve own comment', () => {
accessContext.actions.resolveOwnComment = false
tr.setNodeMarkup(10, undefined, { ...comment.attrs, resolved: true })
const hasAccessToStep = DIContainer.sharedContainer.stepAccessService.validate(
tr.steps[1],
tr.docs[1],
accessContext
)
expect(hasAccessToStep).toEqual(false)
})
it('has no access to create a comment', () => {
accessContext.actions.createComment = false
tr.insert(10, schema.nodeFromJSON(comment))
const hasAccessToStep = DIContainer.sharedContainer.stepAccessService.validate(
tr.steps[1],
tr.docs[1],
accessContext
)
expect(hasAccessToStep).toEqual(false)
})
it('has no access to delete a comment', () => {
accessContext.actions.handleOwnComments = false
tr.delete(10, 11)
const hasAccessToStep = DIContainer.sharedContainer.stepAccessService.validate(
tr.steps[1],
tr.docs[1],
accessContext
)
expect(hasAccessToStep).toEqual(false)
})
})
})