From 400ee5c1b548ba49a34e27525260d82a9f1b91b0 Mon Sep 17 00:00:00 2001 From: asouqi Date: Mon, 9 Mar 2026 08:13:52 +0300 Subject: [PATCH 01/11] Prevent users from manipulating comments (LEAN-5385) --- package.json | 1 + pnpm-lock.yaml | 18 ++- .../V2/Document/DocumentController.ts | 9 +- src/DIContainer/DIContainer.ts | 4 + src/DomainServices/AuthorityService.ts | 24 +++- src/DomainServices/DocumentService.ts | 17 +++ src/DomainServices/StepAccessService.ts | 98 +++++++++++++++ src/Errors.ts | 14 +++ src/InternalErrorCodes.ts | 1 + src/Models/AccessContextModels.ts | 22 ++++ src/Models/CapabilitiesModels.ts | 23 ++++ src/Models/NodeAccessModels.ts | 24 ++++ .../NodeAccess/CommentAccessPolicy.ts | 52 ++++++++ .../NodeAccess/DefaultNodeAccessPolicy.ts | 30 +++++ .../NodeAccess/NodeAccessRegistry.ts | 56 +++++++++ .../DomainLayer/V2/StepAccessService.spec.ts | 112 ++++++++++++++++++ 16 files changed, 489 insertions(+), 16 deletions(-) create mode 100644 src/DomainServices/StepAccessService.ts create mode 100644 src/Models/AccessContextModels.ts create mode 100644 src/Models/CapabilitiesModels.ts create mode 100644 src/Models/NodeAccessModels.ts create mode 100644 src/Utilities/NodeAccess/CommentAccessPolicy.ts create mode 100644 src/Utilities/NodeAccess/DefaultNodeAccessPolicy.ts create mode 100644 src/Utilities/NodeAccess/NodeAccessRegistry.ts create mode 100644 test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts diff --git a/package.json b/package.json index e627357b..52cfb927 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "jest-circus": "29.7.0", "npm-run-all": "4.1.5", "prettier": "3.6.2", + "prosemirror-model": "1.25.0", "ts-node": "10.9.2", "typescript": "4.9.5" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad383770..e136b68a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,6 +252,9 @@ importers: prettier: specifier: 3.6.2 version: 3.6.2 + prosemirror-model: + specifier: 1.25.0 + version: 1.25.0 ts-node: specifier: 10.9.2 version: 10.9.2(@types/node@22.19.0)(typescript@4.9.5) @@ -4224,9 +4227,6 @@ packages: prosemirror-model@1.25.0: resolution: {integrity: sha512-/8XUmxWf0pkj2BmtqZHYJipTBMHIdVjuvFzMvEoxrtyGNmfvdhBiRwYt/eFwy2wA9DtBW3RLqvZnjurEkHaFCw==} - prosemirror-model@1.25.1: - resolution: {integrity: sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg==} - prosemirror-state@1.4.3: resolution: {integrity: sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==} @@ -10272,27 +10272,23 @@ snapshots: dependencies: orderedmap: 2.1.1 - prosemirror-model@1.25.1: - dependencies: - orderedmap: 2.1.1 - prosemirror-state@1.4.3: dependencies: - prosemirror-model: 1.25.1 + prosemirror-model: 1.25.0 prosemirror-transform: 1.10.4 prosemirror-view: 1.39.2 prosemirror-tables@1.7.1: dependencies: prosemirror-keymap: 1.2.2 - prosemirror-model: 1.25.1 + prosemirror-model: 1.25.0 prosemirror-state: 1.4.3 prosemirror-transform: 1.10.4 prosemirror-view: 1.39.2 prosemirror-transform@1.10.4: dependencies: - prosemirror-model: 1.25.1 + prosemirror-model: 1.25.0 prosemirror-utils@1.2.2(prosemirror-model@1.25.0)(prosemirror-state@1.4.3): dependencies: @@ -10301,7 +10297,7 @@ snapshots: prosemirror-view@1.39.2: dependencies: - prosemirror-model: 1.25.1 + prosemirror-model: 1.25.0 prosemirror-state: 1.4.3 prosemirror-transform: 1.10.4 diff --git a/src/Controller/V2/Document/DocumentController.ts b/src/Controller/V2/Document/DocumentController.ts index 9a80feb5..c4db3aab 100644 --- a/src/Controller/V2/Document/DocumentController.ts +++ b/src/Controller/V2/Document/DocumentController.ts @@ -105,7 +105,14 @@ export class DocumentController extends BaseController { projectID, DocumentPermission.WRITE ) - return await DIContainer.sharedContainer.authorityService.receiveSteps(manuscriptID, payload) + const capabilities = await DIContainer.sharedContainer.documentService.getCapabilities( + projectID, + user.id + ) + return await DIContainer.sharedContainer.authorityService.receiveSteps(manuscriptID, payload, { + userId: user.id, + capabilities, + }) } broadcastSteps(manuscriptID: string, result: History) { diff --git a/src/DIContainer/DIContainer.ts b/src/DIContainer/DIContainer.ts index 79a03c0e..8ccd4cfc 100644 --- a/src/DIContainer/DIContainer.ts +++ b/src/DIContainer/DIContainer.ts @@ -31,6 +31,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, @@ -40,6 +41,7 @@ import { } from '../Models/RepositoryModels' import { IServer } from '../Server/IServer' import { Server } from '../Server/Server' +import { createNodeAccessRegistry } from '../Utilities/NodeAccess/NodeAccessRegistry' const prisma = new PrismaClient() @@ -83,6 +85,7 @@ export class DIContainer { readonly eventclient: EventClient readonly eventManager: EventManager readonly socketsService: SocketsService + readonly stepAccessService: StepAccessService /** * WARNING: internal method. @@ -115,6 +118,7 @@ export class DIContainer { ) this.socketsService = new SocketsService() this.documentService = new DocumentService(this.socketsService, repository.documentClient) + this.stepAccessService = new StepAccessService(createNodeAccessRegistry()) } /** diff --git a/src/DomainServices/AuthorityService.ts b/src/DomainServices/AuthorityService.ts index 3a075173..f9a8eda8 100644 --- a/src/DomainServices/AuthorityService.ts +++ b/src/DomainServices/AuthorityService.ts @@ -21,16 +21,23 @@ import { Step } from 'prosemirror-transform' import { History, ModifiedStep, ReceiveSteps } from '../Models/AuthorityModels' import { DB } from '../Models/RepositoryModels' -import { VersionMismatchError } from '../Errors' +import { StepAccessError, VersionMismatchError } from '../Errors' +import { DIContainer } from '../DIContainer/DIContainer' +import { AccessContext } from '../Models/AccessContextModels' export class AuthorityService { constructor(private readonly repository: DB) {} - public async receiveSteps(documentID: string, receiveSteps: ReceiveSteps): Promise { + public async receiveSteps( + documentID: string, + receiveSteps: ReceiveSteps, + accessContext: AccessContext + ): Promise { 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( @@ -82,12 +89,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 }) } diff --git a/src/DomainServices/DocumentService.ts b/src/DomainServices/DocumentService.ts index 0423bfe6..26fd12c7 100644 --- a/src/DomainServices/DocumentService.ts +++ b/src/DomainServices/DocumentService.ts @@ -27,6 +27,7 @@ import { Snapshot } from '../Models/SnapshotModels' import { validateToken } from '../Utilities/JWT/LoginTokenPayload' import { log } from '../Utilities/Logger' import { SocketsService } from './SocketsService' +import { Capabilities } from '../Models/CapabilitiesModels' export enum DocumentPermission { READ, @@ -63,6 +64,22 @@ export class DocumentService { return EMPTY_PERMISSIONS } + async getCapabilities(projectID: string, userID: string): Promise { + const project = await DIContainer.sharedContainer.projectService.getProject(projectID) + const role = DIContainer.sharedContainer.projectService.getUserRole(project, userID) + const isViewer = role === ProjectUserRole.Viewer + const isOwner = role === ProjectUserRole.Owner + const isEditor = role === ProjectUserRole.Editor + + return { + handleOwnComments: !isViewer, + handleOthersComments: isOwner, + resolveOwnComment: !isViewer, + resolveOthersComment: isOwner || isEditor, + createComment: !isViewer, + } + } + public async validateUserAccess( userID: string, projectID: string, diff --git a/src/DomainServices/StepAccessService.ts b/src/DomainServices/StepAccessService.ts new file mode 100644 index 00000000..3e6db3ef --- /dev/null +++ b/src/DomainServices/StepAccessService.ts @@ -0,0 +1,98 @@ +/*! + * © 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 { Attrs, Fragment, Node, Slice } from 'prosemirror-model' +import { AttrStep, ReplaceAroundStep, ReplaceStep, Step } from 'prosemirror-transform' + +import { AccessContext } from '../Models/AccessContextModels' +import { NodeAccessRegistry } from '../Utilities/NodeAccess/NodeAccessRegistry' + +type ExposedSlice = Slice & { + insertAt: (pos: number, fragment: Fragment) => Slice +} + +export class StepAccessService { + constructor(private readonly registry: NodeAccessRegistry) {} + + validate(step: Step, doc: Node, context: AccessContext) { + if (step instanceof ReplaceAroundStep) { + const gap = doc.slice(step.gapFrom, step.gapTo) + const slice = (step.slice as ExposedSlice).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: Node, context: AccessContext) { + if (this.isStepUpdateNodeAttr(step, doc.slice(step.from, step.to))) { + const node = step.slice.content.firstChild! + return !this.findDiff(doc.slice(step.from, step.to).content.firstChild!, node.attrs).find( + (attr) => !this.registry.canEditAttr(node, attr, context) + ) + } + + let hasAccess = true + + doc.slice(step.from, step.to).content.descendants((node) => { + if (!this.registry.canDeleteNode(node, context)) { + hasAccess = false + return false + } + }) + + step.slice.content.descendants((node) => { + if (!this.registry.canInsertNode(node, context)) { + hasAccess = false + return false + } + }) + + return hasAccess + } + + private validateAttrStep(step: AttrStep, doc: Node, context: AccessContext) { + const node = doc.nodeAt(step.pos) + return !!(node && this.registry.canEditAttr(node, step.attr, context)) + } + + private isStepUpdateNodeAttr(step: ReplaceStep, slice: Slice) { + const stepContent = step.slice.content + const sliceContent = slice.content + return ( + stepContent.size === sliceContent.size && + stepContent.childCount === 1 && + sliceContent.childCount === 1 && + stepContent.firstChild!.content.eq(sliceContent.firstChild!.content) + ) + } + + private findDiff(node: Node, attrs: Attrs) { + const keys: string[] = [] + Object.entries(attrs).map(([key, value]) => { + if (!node.hasMarkup(node.type, { ...node.attrs, [key]: value })) { + keys.push(key) + } + }) + return keys + } +} diff --git a/src/Errors.ts b/src/Errors.ts index f13c81cc..393e109a 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -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 { @@ -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 + + 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) + } +} diff --git a/src/InternalErrorCodes.ts b/src/InternalErrorCodes.ts index 7a754e4a..2092ca06 100644 --- a/src/InternalErrorCodes.ts +++ b/src/InternalErrorCodes.ts @@ -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', diff --git a/src/Models/AccessContextModels.ts b/src/Models/AccessContextModels.ts new file mode 100644 index 00000000..d635c84a --- /dev/null +++ b/src/Models/AccessContextModels.ts @@ -0,0 +1,22 @@ +/*! + * © 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 { Capabilities } from './CapabilitiesModels' + +export interface AccessContext { + userId: string + capabilities: Capabilities +} diff --git a/src/Models/CapabilitiesModels.ts b/src/Models/CapabilitiesModels.ts new file mode 100644 index 00000000..5c0dc437 --- /dev/null +++ b/src/Models/CapabilitiesModels.ts @@ -0,0 +1,23 @@ +/*! + * © 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. + */ + +export type Capabilities = { + handleOwnComments: boolean + resolveOwnComment: boolean + handleOthersComments: boolean + resolveOthersComment: boolean + createComment: boolean +} diff --git a/src/Models/NodeAccessModels.ts b/src/Models/NodeAccessModels.ts new file mode 100644 index 00000000..e38cd70c --- /dev/null +++ b/src/Models/NodeAccessModels.ts @@ -0,0 +1,24 @@ +/*! + * © 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 { Node } from 'prosemirror-model' + +import { AccessContext } from './AccessContextModels' + +export interface NodeAccessPolicy { + canEditAttr(node: Node, attr: string, context: AccessContext): boolean + canInsertNode(node: Node, context: AccessContext): boolean + canDeleteNode(node: Node, context: AccessContext): boolean +} diff --git a/src/Utilities/NodeAccess/CommentAccessPolicy.ts b/src/Utilities/NodeAccess/CommentAccessPolicy.ts new file mode 100644 index 00000000..f41f6974 --- /dev/null +++ b/src/Utilities/NodeAccess/CommentAccessPolicy.ts @@ -0,0 +1,52 @@ +/*! + * © 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 { CommentNode } from '@manuscripts/transform' +import { Node } from 'prosemirror-model' + +import { AccessContext } from '../../Models/AccessContextModels' +import { NodeAccessPolicy } from '../../Models/NodeAccessModels' + +export class CommentAccessPolicy implements NodeAccessPolicy { + canInsertNode(_node: Node, context: AccessContext): boolean { + return context.capabilities.createComment + } + + canDeleteNode(node: Node, context: AccessContext): boolean { + return this.isOwn(node as CommentNode, context.userId) + ? context.capabilities.handleOwnComments + : context.capabilities.handleOthersComments + } + + canEditAttr(node: Node, attr: string, context: AccessContext): boolean { + const isOwn = this.isOwn(node as CommentNode, context.userId) + if (attr === 'contents') { + return isOwn + ? context.capabilities.handleOwnComments + : context.capabilities.handleOthersComments + } else if (attr === 'resolved') { + return isOwn + ? context.capabilities.resolveOwnComment + : context.capabilities.resolveOthersComment + } + return true + } + + private isOwn(comment: CommentNode, userId: string) { + const contributions = comment.attrs.contributions + const profileID = !contributions?.length ? undefined : contributions[0].profileID + return profileID && profileID === userId + } +} diff --git a/src/Utilities/NodeAccess/DefaultNodeAccessPolicy.ts b/src/Utilities/NodeAccess/DefaultNodeAccessPolicy.ts new file mode 100644 index 00000000..bc4cde73 --- /dev/null +++ b/src/Utilities/NodeAccess/DefaultNodeAccessPolicy.ts @@ -0,0 +1,30 @@ +/*! + * © 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 { NodeAccessPolicy } from '../../Models/NodeAccessModels' + +export class DefaultNodeAccessPolicy implements NodeAccessPolicy { + canInsertNode(): boolean { + return true + } + + canDeleteNode(): boolean { + return true + } + + canEditAttr(): boolean { + return true + } +} diff --git a/src/Utilities/NodeAccess/NodeAccessRegistry.ts b/src/Utilities/NodeAccess/NodeAccessRegistry.ts new file mode 100644 index 00000000..5366635e --- /dev/null +++ b/src/Utilities/NodeAccess/NodeAccessRegistry.ts @@ -0,0 +1,56 @@ +/*! + * © 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 { schema } from '@manuscripts/transform' +import { Node, NodeType } from 'prosemirror-model' + +import { AccessContext } from '../../Models/AccessContextModels' +import { NodeAccessPolicy } from '../../Models/NodeAccessModels' +import { CommentAccessPolicy } from './CommentAccessPolicy' +import { DefaultNodeAccessPolicy } from './DefaultNodeAccessPolicy' + +export class NodeAccessRegistry { + private policies = new Map() + private readonly defaultPolicy: NodeAccessPolicy + + constructor(defaultPolicy?: NodeAccessPolicy) { + this.defaultPolicy = defaultPolicy ?? new DefaultNodeAccessPolicy() + } + + register(nodeType: NodeType, policy: NodeAccessPolicy): this { + this.policies.set(nodeType, policy) + return this + } + + getPolicy(nodeType: NodeType): NodeAccessPolicy { + return this.policies.get(nodeType) ?? this.defaultPolicy + } + + canInsertNode(node: Node, context: AccessContext): boolean { + return this.getPolicy(node.type).canInsertNode(node, context) + } + + canDeleteNode(node: Node, context: AccessContext): boolean { + return this.getPolicy(node.type).canDeleteNode(node, context) + } + + canEditAttr(node: Node, attr: string, context: AccessContext): boolean { + return this.getPolicy(node.type).canEditAttr(node, attr, context) + } +} + +export function createNodeAccessRegistry(): NodeAccessRegistry { + return new NodeAccessRegistry().register(schema.nodes.comment, new CommentAccessPolicy()) +} diff --git a/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts b/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts new file mode 100644 index 00000000..be69fb22 --- /dev/null +++ b/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts @@ -0,0 +1,112 @@ +/*! + * © 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 { schema } from '@manuscripts/transform' +import { Transform } from 'prosemirror-transform' + +import { DIContainer } from '../../../../../src/DIContainer/DIContainer' +import { AccessContext } from '../../../../../src/Models/AccessContextModels' +import { TEST_TIMEOUT } from '../../../../utilities/testSetup' + +jest.setTimeout(TEST_TIMEOUT) + +beforeEach(async () => { + ;(DIContainer as any)._sharedContainer = null + await DIContainer.init() +}) + +afterEach(() => { + jest.clearAllMocks() +}) + +const comment = { + type: 'comment', + attrs: { + id: 'MPCommentAnnotation:29D4335B', + contents: 'comment content', + target: 'MPParagraphElement:06D94BD3', + resolved: false, + contributions: [ + { + _id: 'MPContribution:1164DD22', + profileID: 'MPUserProfile:01', + timestamp: 1, + objectType: 'MPContribution', + }, + ], + originalText: '', + }, +} + +describe('StepAccessService', () => { + const accessContext: AccessContext = { + userId: 'MPUserProfile:01', + capabilities: { + resolveOthersComment: true, + resolveOwnComment: true, + handleOthersComments: true, + handleOwnComments: true, + createComment: true, + }, + } + const doc = schema.nodes.doc.createAndFill()! + const tr = new Transform(doc) + tr.insert(10, schema.nodeFromJSON(comment)) + + describe('validate', () => { + it('has no access to resolve other comment', () => { + accessContext.userId = 'MPUserProfile:02' + accessContext.capabilities.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.capabilities.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.capabilities.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.capabilities.handleOwnComments = false + tr.delete(10, 11) + const hasAccessToStep = DIContainer.sharedContainer.stepAccessService.validate( + tr.steps[1], + tr.docs[1], + accessContext + ) + expect(hasAccessToStep).toEqual(false) + }) + }) +}) From b0b14e829422dc96d0cf910f248a1fe43fd7eeec Mon Sep 17 00:00:00 2001 From: asouqi Date: Mon, 9 Mar 2026 08:25:11 +0300 Subject: [PATCH 02/11] Prevent users from manipulating comments (LEAN-5385) --- .../NodeAccess/CommentAccessPolicy.ts | 2 +- .../DomainLayer/V2/StepAccessService.spec.ts | 30 ++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/Utilities/NodeAccess/CommentAccessPolicy.ts b/src/Utilities/NodeAccess/CommentAccessPolicy.ts index f41f6974..f4e6e5f1 100644 --- a/src/Utilities/NodeAccess/CommentAccessPolicy.ts +++ b/src/Utilities/NodeAccess/CommentAccessPolicy.ts @@ -41,7 +41,7 @@ export class CommentAccessPolicy implements NodeAccessPolicy { ? context.capabilities.resolveOwnComment : context.capabilities.resolveOthersComment } - return true + return false } private isOwn(comment: CommentNode, userId: string) { diff --git a/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts b/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts index be69fb22..c3bacf61 100644 --- a/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts +++ b/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts @@ -23,9 +23,25 @@ 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', + capabilities: { + resolveOthersComment: true, + resolveOwnComment: true, + handleOthersComments: true, + handleOwnComments: true, + createComment: true, + }, + } + const emptyDoc = schema.nodes.doc.createAndFill()! + tr = new Transform(emptyDoc) + tr.insert(10, schema.nodeFromJSON(comment)) }) afterEach(() => { @@ -52,20 +68,6 @@ const comment = { } describe('StepAccessService', () => { - const accessContext: AccessContext = { - userId: 'MPUserProfile:01', - capabilities: { - resolveOthersComment: true, - resolveOwnComment: true, - handleOthersComments: true, - handleOwnComments: true, - createComment: true, - }, - } - const doc = schema.nodes.doc.createAndFill()! - const tr = new Transform(doc) - tr.insert(10, schema.nodeFromJSON(comment)) - describe('validate', () => { it('has no access to resolve other comment', () => { accessContext.userId = 'MPUserProfile:02' From 29bd14f2462b91d4e28eeb6e8e41058e7c7cf856 Mon Sep 17 00:00:00 2001 From: asouqi Date: Sun, 12 Apr 2026 06:55:10 +0300 Subject: [PATCH 03/11] review update --- src/DomainServices/StepAccessService.ts | 5 +++-- src/Utilities/NodeAccess/CommentAccessPolicy.ts | 15 ++++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/DomainServices/StepAccessService.ts b/src/DomainServices/StepAccessService.ts index 3e6db3ef..25ece231 100644 --- a/src/DomainServices/StepAccessService.ts +++ b/src/DomainServices/StepAccessService.ts @@ -46,8 +46,9 @@ export class StepAccessService { private validateReplaceStep(step: ReplaceStep, doc: Node, context: AccessContext) { if (this.isStepUpdateNodeAttr(step, doc.slice(step.from, step.to))) { const node = step.slice.content.firstChild! - return !this.findDiff(doc.slice(step.from, step.to).content.firstChild!, node.attrs).find( - (attr) => !this.registry.canEditAttr(node, attr, context) + const nodeDB = doc.slice(step.from, step.to).content.firstChild! + return !this.findDiff(nodeDB, node.attrs).find( + (attr) => !this.registry.canEditAttr(nodeDB, attr, context) ) } diff --git a/src/Utilities/NodeAccess/CommentAccessPolicy.ts b/src/Utilities/NodeAccess/CommentAccessPolicy.ts index f4e6e5f1..525dadfe 100644 --- a/src/Utilities/NodeAccess/CommentAccessPolicy.ts +++ b/src/Utilities/NodeAccess/CommentAccessPolicy.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { CommentNode } from '@manuscripts/transform' +import { CommentNode, schema } from '@manuscripts/transform' import { Node } from 'prosemirror-model' import { AccessContext } from '../../Models/AccessContextModels' @@ -32,11 +32,12 @@ export class CommentAccessPolicy implements NodeAccessPolicy { canEditAttr(node: Node, attr: string, context: AccessContext): boolean { const isOwn = this.isOwn(node as CommentNode, context.userId) - if (attr === 'contents') { + + if (this.schemaHasAttr('contents') && attr === 'contents') { return isOwn ? context.capabilities.handleOwnComments : context.capabilities.handleOthersComments - } else if (attr === 'resolved') { + } else if (this.schemaHasAttr('resolved') && attr === 'resolved') { return isOwn ? context.capabilities.resolveOwnComment : context.capabilities.resolveOthersComment @@ -45,8 +46,12 @@ export class CommentAccessPolicy implements NodeAccessPolicy { } private isOwn(comment: CommentNode, userId: string) { - const contributions = comment.attrs.contributions - const profileID = !contributions?.length ? undefined : contributions[0].profileID + const profileID = this.schemaHasAttr('userID') && comment.attrs.userID return profileID && profileID === userId } + + private schemaHasAttr(attr: string) { + const commentAttrsSpec = schema.nodes.comment.spec + return commentAttrsSpec && attr in commentAttrsSpec + } } From 12bcf28f64570a3fc723a25eef0e175dd01cbac0 Mon Sep 17 00:00:00 2001 From: asouqi Date: Sun, 12 Apr 2026 07:13:53 +0300 Subject: [PATCH 04/11] review update --- src/Utilities/NodeAccess/CommentAccessPolicy.ts | 2 +- .../suites/unit/DomainLayer/V2/StepAccessService.spec.ts | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/Utilities/NodeAccess/CommentAccessPolicy.ts b/src/Utilities/NodeAccess/CommentAccessPolicy.ts index 525dadfe..c67aed0a 100644 --- a/src/Utilities/NodeAccess/CommentAccessPolicy.ts +++ b/src/Utilities/NodeAccess/CommentAccessPolicy.ts @@ -51,7 +51,7 @@ export class CommentAccessPolicy implements NodeAccessPolicy { } private schemaHasAttr(attr: string) { - const commentAttrsSpec = schema.nodes.comment.spec + const commentAttrsSpec = schema.nodes.comment.spec.attrs return commentAttrsSpec && attr in commentAttrsSpec } } diff --git a/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts b/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts index c3bacf61..7e485225 100644 --- a/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts +++ b/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts @@ -55,14 +55,7 @@ const comment = { contents: 'comment content', target: 'MPParagraphElement:06D94BD3', resolved: false, - contributions: [ - { - _id: 'MPContribution:1164DD22', - profileID: 'MPUserProfile:01', - timestamp: 1, - objectType: 'MPContribution', - }, - ], + userID: 'MPUserProfile:01', originalText: '', }, } From 4c67cb702ca2425fe277e45e3dc27013774671d2 Mon Sep 17 00:00:00 2001 From: asouqi Date: Mon, 13 Apr 2026 00:39:25 +0300 Subject: [PATCH 05/11] review update --- .../NodeAccess/CommentAccessPolicy.ts | 57 ------------------- .../NodeAccess/DefaultNodeAccessPolicy.ts | 30 ---------- .../NodeAccess/NodeAccessRegistry.ts | 32 +++-------- 3 files changed, 8 insertions(+), 111 deletions(-) delete mode 100644 src/Utilities/NodeAccess/CommentAccessPolicy.ts delete mode 100644 src/Utilities/NodeAccess/DefaultNodeAccessPolicy.ts diff --git a/src/Utilities/NodeAccess/CommentAccessPolicy.ts b/src/Utilities/NodeAccess/CommentAccessPolicy.ts deleted file mode 100644 index c67aed0a..00000000 --- a/src/Utilities/NodeAccess/CommentAccessPolicy.ts +++ /dev/null @@ -1,57 +0,0 @@ -/*! - * © 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 { CommentNode, schema } from '@manuscripts/transform' -import { Node } from 'prosemirror-model' - -import { AccessContext } from '../../Models/AccessContextModels' -import { NodeAccessPolicy } from '../../Models/NodeAccessModels' - -export class CommentAccessPolicy implements NodeAccessPolicy { - canInsertNode(_node: Node, context: AccessContext): boolean { - return context.capabilities.createComment - } - - canDeleteNode(node: Node, context: AccessContext): boolean { - return this.isOwn(node as CommentNode, context.userId) - ? context.capabilities.handleOwnComments - : context.capabilities.handleOthersComments - } - - canEditAttr(node: Node, attr: string, context: AccessContext): boolean { - const isOwn = this.isOwn(node as CommentNode, context.userId) - - if (this.schemaHasAttr('contents') && attr === 'contents') { - return isOwn - ? context.capabilities.handleOwnComments - : context.capabilities.handleOthersComments - } else if (this.schemaHasAttr('resolved') && attr === 'resolved') { - return isOwn - ? context.capabilities.resolveOwnComment - : context.capabilities.resolveOthersComment - } - return false - } - - private isOwn(comment: CommentNode, userId: string) { - const profileID = this.schemaHasAttr('userID') && comment.attrs.userID - return profileID && profileID === userId - } - - private schemaHasAttr(attr: string) { - const commentAttrsSpec = schema.nodes.comment.spec.attrs - return commentAttrsSpec && attr in commentAttrsSpec - } -} diff --git a/src/Utilities/NodeAccess/DefaultNodeAccessPolicy.ts b/src/Utilities/NodeAccess/DefaultNodeAccessPolicy.ts deleted file mode 100644 index bc4cde73..00000000 --- a/src/Utilities/NodeAccess/DefaultNodeAccessPolicy.ts +++ /dev/null @@ -1,30 +0,0 @@ -/*! - * © 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 { NodeAccessPolicy } from '../../Models/NodeAccessModels' - -export class DefaultNodeAccessPolicy implements NodeAccessPolicy { - canInsertNode(): boolean { - return true - } - - canDeleteNode(): boolean { - return true - } - - canEditAttr(): boolean { - return true - } -} diff --git a/src/Utilities/NodeAccess/NodeAccessRegistry.ts b/src/Utilities/NodeAccess/NodeAccessRegistry.ts index 5366635e..24a87056 100644 --- a/src/Utilities/NodeAccess/NodeAccessRegistry.ts +++ b/src/Utilities/NodeAccess/NodeAccessRegistry.ts @@ -14,43 +14,27 @@ * limitations under the License. */ import { schema } from '@manuscripts/transform' -import { Node, NodeType } from 'prosemirror-model' +import { Node } from 'prosemirror-model' import { AccessContext } from '../../Models/AccessContextModels' -import { NodeAccessPolicy } from '../../Models/NodeAccessModels' -import { CommentAccessPolicy } from './CommentAccessPolicy' -import { DefaultNodeAccessPolicy } from './DefaultNodeAccessPolicy' export class NodeAccessRegistry { - private policies = new Map() - private readonly defaultPolicy: NodeAccessPolicy - - constructor(defaultPolicy?: NodeAccessPolicy) { - this.defaultPolicy = defaultPolicy ?? new DefaultNodeAccessPolicy() - } - - register(nodeType: NodeType, policy: NodeAccessPolicy): this { - this.policies.set(nodeType, policy) - return this - } - - getPolicy(nodeType: NodeType): NodeAccessPolicy { - return this.policies.get(nodeType) ?? this.defaultPolicy - } - canInsertNode(node: Node, context: AccessContext): boolean { - return this.getPolicy(node.type).canInsertNode(node, context) + const canInsertNode = schema.nodes[node.type.name].spec.canInsertNode + return canInsertNode ? canInsertNode(node, context) : true } canDeleteNode(node: Node, context: AccessContext): boolean { - return this.getPolicy(node.type).canDeleteNode(node, context) + const canDeleteNode = schema.nodes[node.type.name].spec.canDeleteNode + return canDeleteNode ? canDeleteNode(node, context) : true } canEditAttr(node: Node, attr: string, context: AccessContext): boolean { - return this.getPolicy(node.type).canEditAttr(node, attr, context) + const canEditAttr = schema.nodes[node.type.name].spec.canEditAttr + return canEditAttr ? canEditAttr(node, attr, context) : true } } export function createNodeAccessRegistry(): NodeAccessRegistry { - return new NodeAccessRegistry().register(schema.nodes.comment, new CommentAccessPolicy()) + return new NodeAccessRegistry() } From 2069390b2fa8c3d21ae8f58a70295b41c44c77e7 Mon Sep 17 00:00:00 2001 From: asouqi Date: Mon, 13 Apr 2026 02:51:45 +0300 Subject: [PATCH 06/11] review update --- src/DomainServices/AuthorityService.ts | 3 +-- src/DomainServices/DocumentService.ts | 3 +-- src/DomainServices/StepAccessService.ts | 2 +- src/Models/AccessContextModels.ts | 22 ----------------- src/Models/CapabilitiesModels.ts | 23 ------------------ src/Models/NodeAccessModels.ts | 24 ------------------- .../NodeAccess/NodeAccessRegistry.ts | 4 +--- .../DomainLayer/V2/StepAccessService.spec.ts | 3 +-- 8 files changed, 5 insertions(+), 79 deletions(-) delete mode 100644 src/Models/AccessContextModels.ts delete mode 100644 src/Models/CapabilitiesModels.ts delete mode 100644 src/Models/NodeAccessModels.ts diff --git a/src/DomainServices/AuthorityService.ts b/src/DomainServices/AuthorityService.ts index f9a8eda8..3a3e2f74 100644 --- a/src/DomainServices/AuthorityService.ts +++ b/src/DomainServices/AuthorityService.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { getVersion, JSONProsemirrorNode, schema } from '@manuscripts/transform' +import { getVersion, JSONProsemirrorNode, AccessContext, schema } from '@manuscripts/transform' import { Prisma } from '@prisma/client' import { JsonObject } from '@prisma/client/runtime/library' import { Step } from 'prosemirror-transform' @@ -23,7 +23,6 @@ import { History, ModifiedStep, ReceiveSteps } from '../Models/AuthorityModels' import { DB } from '../Models/RepositoryModels' import { StepAccessError, VersionMismatchError } from '../Errors' import { DIContainer } from '../DIContainer/DIContainer' -import { AccessContext } from '../Models/AccessContextModels' export class AuthorityService { constructor(private readonly repository: DB) {} diff --git a/src/DomainServices/DocumentService.ts b/src/DomainServices/DocumentService.ts index 26fd12c7..55967e1d 100644 --- a/src/DomainServices/DocumentService.ts +++ b/src/DomainServices/DocumentService.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { schema, validateManuscriptNode } from '@manuscripts/transform' +import { schema, Capabilities, validateManuscriptNode } from '@manuscripts/transform' import { IncomingMessage } from 'http' import type { DocumentClient } from 'src/Models/RepositoryModels' import { Duplex } from 'stream' @@ -27,7 +27,6 @@ import { Snapshot } from '../Models/SnapshotModels' import { validateToken } from '../Utilities/JWT/LoginTokenPayload' import { log } from '../Utilities/Logger' import { SocketsService } from './SocketsService' -import { Capabilities } from '../Models/CapabilitiesModels' export enum DocumentPermission { READ, diff --git a/src/DomainServices/StepAccessService.ts b/src/DomainServices/StepAccessService.ts index 25ece231..400e3e65 100644 --- a/src/DomainServices/StepAccessService.ts +++ b/src/DomainServices/StepAccessService.ts @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { AccessContext } from '@manuscripts/transform' import { Attrs, Fragment, Node, Slice } from 'prosemirror-model' import { AttrStep, ReplaceAroundStep, ReplaceStep, Step } from 'prosemirror-transform' -import { AccessContext } from '../Models/AccessContextModels' import { NodeAccessRegistry } from '../Utilities/NodeAccess/NodeAccessRegistry' type ExposedSlice = Slice & { diff --git a/src/Models/AccessContextModels.ts b/src/Models/AccessContextModels.ts deleted file mode 100644 index d635c84a..00000000 --- a/src/Models/AccessContextModels.ts +++ /dev/null @@ -1,22 +0,0 @@ -/*! - * © 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 { Capabilities } from './CapabilitiesModels' - -export interface AccessContext { - userId: string - capabilities: Capabilities -} diff --git a/src/Models/CapabilitiesModels.ts b/src/Models/CapabilitiesModels.ts deleted file mode 100644 index 5c0dc437..00000000 --- a/src/Models/CapabilitiesModels.ts +++ /dev/null @@ -1,23 +0,0 @@ -/*! - * © 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. - */ - -export type Capabilities = { - handleOwnComments: boolean - resolveOwnComment: boolean - handleOthersComments: boolean - resolveOthersComment: boolean - createComment: boolean -} diff --git a/src/Models/NodeAccessModels.ts b/src/Models/NodeAccessModels.ts deleted file mode 100644 index e38cd70c..00000000 --- a/src/Models/NodeAccessModels.ts +++ /dev/null @@ -1,24 +0,0 @@ -/*! - * © 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 { Node } from 'prosemirror-model' - -import { AccessContext } from './AccessContextModels' - -export interface NodeAccessPolicy { - canEditAttr(node: Node, attr: string, context: AccessContext): boolean - canInsertNode(node: Node, context: AccessContext): boolean - canDeleteNode(node: Node, context: AccessContext): boolean -} diff --git a/src/Utilities/NodeAccess/NodeAccessRegistry.ts b/src/Utilities/NodeAccess/NodeAccessRegistry.ts index 24a87056..5f1ee517 100644 --- a/src/Utilities/NodeAccess/NodeAccessRegistry.ts +++ b/src/Utilities/NodeAccess/NodeAccessRegistry.ts @@ -13,11 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { schema } from '@manuscripts/transform' +import { schema, AccessContext } from '@manuscripts/transform' import { Node } from 'prosemirror-model' -import { AccessContext } from '../../Models/AccessContextModels' - export class NodeAccessRegistry { canInsertNode(node: Node, context: AccessContext): boolean { const canInsertNode = schema.nodes[node.type.name].spec.canInsertNode diff --git a/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts b/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts index 7e485225..27279845 100644 --- a/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts +++ b/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts @@ -14,11 +14,10 @@ * limitations under the License. */ -import { schema } from '@manuscripts/transform' +import { schema, AccessContext } from '@manuscripts/transform' import { Transform } from 'prosemirror-transform' import { DIContainer } from '../../../../../src/DIContainer/DIContainer' -import { AccessContext } from '../../../../../src/Models/AccessContextModels' import { TEST_TIMEOUT } from '../../../../utilities/testSetup' jest.setTimeout(TEST_TIMEOUT) From aa7fb10e13c1f966c831a84514f90e5bd63f5db8 Mon Sep 17 00:00:00 2001 From: asouqi Date: Thu, 23 Apr 2026 21:28:17 +0300 Subject: [PATCH 07/11] review update --- package.json | 1 - pnpm-lock.yaml | 3 - .../V2/Document/DocumentController.ts | 3 +- src/DomainServices/AuthorityService.ts | 7 ++- src/DomainServices/DocumentService.ts | 4 +- src/DomainServices/StepAccessService.ts | 32 ++++++----- src/Models/AccessContextModels.ts | 25 +++++++++ src/Models/CapabilitiesModels.ts | 24 ++++++++ src/Models/NodeAccessModels.ts | 24 ++++++++ .../NodeAccess/CommentAccessPolicy.ts | 55 +++++++++++++++++++ .../NodeAccess/DefaultNodeAccessPolicy.ts | 33 +++++++++++ .../NodeAccess/NodeAccessRegistry.ts | 41 ++++++++++---- .../DomainLayer/V2/StepAccessService.spec.ts | 4 +- 13 files changed, 219 insertions(+), 37 deletions(-) create mode 100644 src/Models/AccessContextModels.ts create mode 100644 src/Models/CapabilitiesModels.ts create mode 100644 src/Models/NodeAccessModels.ts create mode 100644 src/Utilities/NodeAccess/CommentAccessPolicy.ts create mode 100644 src/Utilities/NodeAccess/DefaultNodeAccessPolicy.ts diff --git a/package.json b/package.json index 3af8979e..59a262ae 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,6 @@ "jest-circus": "29.7.0", "npm-run-all": "4.1.5", "prettier": "3.6.2", - "prosemirror-model": "1.25.0", "ts-node": "10.9.2", "typescript": "4.9.5" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3566f625..cc619bae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,9 +249,6 @@ importers: prettier: specifier: 3.6.2 version: 3.6.2 - prosemirror-model: - specifier: 1.25.0 - version: 1.25.0 ts-node: specifier: 10.9.2 version: 10.9.2(@types/node@22.19.0)(typescript@4.9.5) diff --git a/src/Controller/V2/Document/DocumentController.ts b/src/Controller/V2/Document/DocumentController.ts index c4db3aab..f65cda5f 100644 --- a/src/Controller/V2/Document/DocumentController.ts +++ b/src/Controller/V2/Document/DocumentController.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +import { schemaToCapabilityAdapter } from '@manuscripts/transform' import { ProjectPermission } from '../../..//Models/ProjectModels' import { DIContainer } from '../../../DIContainer/DIContainer' import { DocumentPermission } from '../../../DomainServices/DocumentService' @@ -112,6 +112,7 @@ export class DocumentController extends BaseController { return await DIContainer.sharedContainer.authorityService.receiveSteps(manuscriptID, payload, { userId: user.id, capabilities, + adapter: schemaToCapabilityAdapter }) } diff --git a/src/DomainServices/AuthorityService.ts b/src/DomainServices/AuthorityService.ts index 3a3e2f74..ba342b99 100644 --- a/src/DomainServices/AuthorityService.ts +++ b/src/DomainServices/AuthorityService.ts @@ -14,15 +14,16 @@ * limitations under the License. */ -import { getVersion, JSONProsemirrorNode, AccessContext, schema } from '@manuscripts/transform' +import { getVersion, JSONProsemirrorNode, 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 { StepAccessError, VersionMismatchError } from '../Errors' +import { AccessContext } from '../Models/AccessContextModels' import { History, ModifiedStep, ReceiveSteps } from '../Models/AuthorityModels' import { DB } from '../Models/RepositoryModels' -import { StepAccessError, VersionMismatchError } from '../Errors' -import { DIContainer } from '../DIContainer/DIContainer' export class AuthorityService { constructor(private readonly repository: DB) {} diff --git a/src/DomainServices/DocumentService.ts b/src/DomainServices/DocumentService.ts index 55967e1d..82b255f9 100644 --- a/src/DomainServices/DocumentService.ts +++ b/src/DomainServices/DocumentService.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { schema, Capabilities, validateManuscriptNode } from '@manuscripts/transform' +import { schema, validateManuscriptNode } from '@manuscripts/transform' import { IncomingMessage } from 'http' import type { DocumentClient } from 'src/Models/RepositoryModels' import { Duplex } from 'stream' @@ -22,6 +22,7 @@ import { ErrorEvent, WebSocket, WebSocketServer } from 'ws' import { DIContainer } from '../DIContainer/DIContainer' import { MissingManuscriptError, RoleDoesNotPermitOperationError } from '../Errors' +import { Capabilities } from '../Models/CapabilitiesModels' import { ProjectUserRole } from '../Models/ProjectModels' import { Snapshot } from '../Models/SnapshotModels' import { validateToken } from '../Utilities/JWT/LoginTokenPayload' @@ -76,6 +77,7 @@ export class DocumentService { resolveOwnComment: !isViewer, resolveOthersComment: isOwner || isEditor, createComment: !isViewer, + editArticle: !isViewer, } } diff --git a/src/DomainServices/StepAccessService.ts b/src/DomainServices/StepAccessService.ts index 400e3e65..f75d1abc 100644 --- a/src/DomainServices/StepAccessService.ts +++ b/src/DomainServices/StepAccessService.ts @@ -13,23 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { AccessContext } from '@manuscripts/transform' -import { Attrs, Fragment, Node, Slice } from 'prosemirror-model' +import { ManuscriptNode } from '@manuscripts/transform' import { AttrStep, ReplaceAroundStep, ReplaceStep, Step } from 'prosemirror-transform' +import { AccessContext } from '../Models/AccessContextModels' import { NodeAccessRegistry } from '../Utilities/NodeAccess/NodeAccessRegistry' -type ExposedSlice = Slice & { - insertAt: (pos: number, fragment: Fragment) => Slice +type ExposedSlice = T & { + insertAt: (pos: number, fragment: F) => T } export class StepAccessService { constructor(private readonly registry: NodeAccessRegistry) {} - validate(step: Step, doc: Node, context: AccessContext) { + 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).insertAt(step.insert, gap.content) + const slice = ( + step.slice as ExposedSlice + ).insertAt(step.insert, gap.content) return this.validateReplaceStep(new ReplaceStep(step.from, step.to, slice), doc, context) } @@ -43,11 +45,11 @@ export class StepAccessService { return true } - private validateReplaceStep(step: ReplaceStep, doc: Node, context: AccessContext) { - if (this.isStepUpdateNodeAttr(step, doc.slice(step.from, step.to))) { + 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.attrs).find( + return !this.findDiff(nodeDB, node).find( (attr) => !this.registry.canEditAttr(nodeDB, attr, context) ) } @@ -71,14 +73,14 @@ export class StepAccessService { return hasAccess } - private validateAttrStep(step: AttrStep, doc: Node, context: AccessContext) { + private validateAttrStep(step: AttrStep, doc: ManuscriptNode, context: AccessContext) { const node = doc.nodeAt(step.pos) return !!(node && this.registry.canEditAttr(node, step.attr, context)) } - private isStepUpdateNodeAttr(step: ReplaceStep, slice: Slice) { + private isStepUpdateNodeAttr(step: ReplaceStep, doc: ManuscriptNode) { const stepContent = step.slice.content - const sliceContent = slice.content + const sliceContent = doc.slice(step.from, step.to).content return ( stepContent.size === sliceContent.size && stepContent.childCount === 1 && @@ -87,10 +89,10 @@ export class StepAccessService { ) } - private findDiff(node: Node, attrs: Attrs) { + private findDiff(nodeA: ManuscriptNode, nodeB: ManuscriptNode) { const keys: string[] = [] - Object.entries(attrs).map(([key, value]) => { - if (!node.hasMarkup(node.type, { ...node.attrs, [key]: value })) { + Object.entries(nodeB.attrs).map(([key, value]) => { + if (!nodeA.hasMarkup(nodeA.type, { ...nodeA.attrs, [key]: value })) { keys.push(key) } }) diff --git a/src/Models/AccessContextModels.ts b/src/Models/AccessContextModels.ts new file mode 100644 index 00000000..a6a22fa6 --- /dev/null +++ b/src/Models/AccessContextModels.ts @@ -0,0 +1,25 @@ +/*! + * © 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 { SchemaAdapter } from '@manuscripts/transform' + +import { Capabilities } from './CapabilitiesModels' + +export interface AccessContext { + userId: string + capabilities: Capabilities + adapter: SchemaAdapter +} diff --git a/src/Models/CapabilitiesModels.ts b/src/Models/CapabilitiesModels.ts new file mode 100644 index 00000000..d1d10762 --- /dev/null +++ b/src/Models/CapabilitiesModels.ts @@ -0,0 +1,24 @@ +/*! + * © 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. + */ + +export type Capabilities = { + handleOwnComments: boolean + resolveOwnComment: boolean + handleOthersComments: boolean + resolveOthersComment: boolean + createComment: boolean + editArticle: boolean +} diff --git a/src/Models/NodeAccessModels.ts b/src/Models/NodeAccessModels.ts new file mode 100644 index 00000000..da1e9053 --- /dev/null +++ b/src/Models/NodeAccessModels.ts @@ -0,0 +1,24 @@ +/*! + * © 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 { ManuscriptNode } from '@manuscripts/transform' + +import { AccessContext } from './AccessContextModels' + +export interface NodeAccessPolicy { + canInsertNode(node: ManuscriptNode, context: AccessContext): boolean + canDeleteNode(node: ManuscriptNode, context: AccessContext): boolean + canEditAttr(node: ManuscriptNode, attr: string, context: AccessContext): boolean +} diff --git a/src/Utilities/NodeAccess/CommentAccessPolicy.ts b/src/Utilities/NodeAccess/CommentAccessPolicy.ts new file mode 100644 index 00000000..d7a9ef87 --- /dev/null +++ b/src/Utilities/NodeAccess/CommentAccessPolicy.ts @@ -0,0 +1,55 @@ +/*! + * © 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 { ManuscriptNode } from '@manuscripts/transform' + +import { AccessContext } from '../../Models/AccessContextModels' +import { NodeAccessPolicy } from '../../Models/NodeAccessModels' + +export class CommentAccessPolicy implements NodeAccessPolicy { + canInsertNode(_node: ManuscriptNode, context: AccessContext): boolean { + return context.capabilities.createComment + } + + canDeleteNode(node: ManuscriptNode, context: AccessContext): boolean { + return context.adapter.getNodeOwnerId(node) === context.userId + ? context.capabilities.handleOwnComments + : context.capabilities.handleOthersComments + } + + canEditAttr(node: ManuscriptNode, attr: string, context: AccessContext): boolean { + const attributeCapabilities = context.adapter.getRequiredCapabilityAttr(node, attr) + if (!attributeCapabilities) { + return false + } + + const isOwn = context.adapter.getNodeOwnerId(node) === context.userId + + if (isOwn) { + return ( + (attributeCapabilities.has('handleOwnComments') && + context.capabilities.handleOwnComments) || + (attributeCapabilities.has('resolveOwnComment') && context.capabilities.resolveOwnComment) + ) + } + + return ( + (attributeCapabilities.has('handleOthersComments') && + context.capabilities.handleOthersComments) || + (attributeCapabilities.has('resolveOthersComment') && + context.capabilities.resolveOthersComment) + ) + } +} diff --git a/src/Utilities/NodeAccess/DefaultNodeAccessPolicy.ts b/src/Utilities/NodeAccess/DefaultNodeAccessPolicy.ts new file mode 100644 index 00000000..23204cb9 --- /dev/null +++ b/src/Utilities/NodeAccess/DefaultNodeAccessPolicy.ts @@ -0,0 +1,33 @@ +/*! + * © 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 { ManuscriptNode } from '@manuscripts/transform' + +import { AccessContext } from '../../Models/AccessContextModels' +import { NodeAccessPolicy } from '../../Models/NodeAccessModels' + +export class DefaultNodeAccessPolicy implements NodeAccessPolicy { + canDeleteNode(_: ManuscriptNode, context: AccessContext): boolean { + return context.capabilities.editArticle + } + + canEditAttr(_: ManuscriptNode, __: string, context: AccessContext): boolean { + return context.capabilities.editArticle + } + + canInsertNode(_: ManuscriptNode, context: AccessContext): boolean { + return context.capabilities.editArticle + } +} diff --git a/src/Utilities/NodeAccess/NodeAccessRegistry.ts b/src/Utilities/NodeAccess/NodeAccessRegistry.ts index 5f1ee517..18ebb9b9 100644 --- a/src/Utilities/NodeAccess/NodeAccessRegistry.ts +++ b/src/Utilities/NodeAccess/NodeAccessRegistry.ts @@ -13,26 +13,43 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { schema, AccessContext } from '@manuscripts/transform' -import { Node } from 'prosemirror-model' +import { ManuscriptNode, ManuscriptNodeType, schema } from '@manuscripts/transform' + +import { AccessContext } from '../../Models/AccessContextModels' +import { NodeAccessPolicy } from '../../Models/NodeAccessModels' +import { CommentAccessPolicy } from './CommentAccessPolicy' +import { DefaultNodeAccessPolicy } from './DefaultNodeAccessPolicy' export class NodeAccessRegistry { - canInsertNode(node: Node, context: AccessContext): boolean { - const canInsertNode = schema.nodes[node.type.name].spec.canInsertNode - return canInsertNode ? canInsertNode(node, context) : true + private policies = new Map() + private readonly defaultPolicy: NodeAccessPolicy + + constructor(defaultPolicy?: NodeAccessPolicy) { + this.defaultPolicy = defaultPolicy ?? new DefaultNodeAccessPolicy() + } + + register(nodeType: ManuscriptNodeType, policy: NodeAccessPolicy): this { + this.policies.set(nodeType, policy) + return this + } + + getPolicy(nodeType: ManuscriptNodeType): NodeAccessPolicy { + return this.policies.get(nodeType) ?? this.defaultPolicy + } + + canInsertNode(node: ManuscriptNode, context: AccessContext): boolean { + return this.getPolicy(node.type).canInsertNode(node, context) } - canDeleteNode(node: Node, context: AccessContext): boolean { - const canDeleteNode = schema.nodes[node.type.name].spec.canDeleteNode - return canDeleteNode ? canDeleteNode(node, context) : true + canDeleteNode(node: ManuscriptNode, context: AccessContext): boolean { + return this.getPolicy(node.type).canDeleteNode(node, context) } - canEditAttr(node: Node, attr: string, context: AccessContext): boolean { - const canEditAttr = schema.nodes[node.type.name].spec.canEditAttr - return canEditAttr ? canEditAttr(node, attr, context) : true + canEditAttr(node: ManuscriptNode, attr: string, context: AccessContext): boolean { + return this.getPolicy(node.type).canEditAttr(node, attr, context) } } export function createNodeAccessRegistry(): NodeAccessRegistry { - return new NodeAccessRegistry() + return new NodeAccessRegistry().register(schema.nodes.comment, new CommentAccessPolicy()) } diff --git a/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts b/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts index 27279845..963c246e 100644 --- a/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts +++ b/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts @@ -14,10 +14,11 @@ * limitations under the License. */ -import { schema, AccessContext } from '@manuscripts/transform' +import { schema} from '@manuscripts/transform' import { Transform } from 'prosemirror-transform' import { DIContainer } from '../../../../../src/DIContainer/DIContainer' +import { AccessContext } from '../../../../../src/Models/AccessContextModels' import { TEST_TIMEOUT } from '../../../../utilities/testSetup' jest.setTimeout(TEST_TIMEOUT) @@ -36,6 +37,7 @@ beforeEach(async () => { handleOthersComments: true, handleOwnComments: true, createComment: true, + editArticle: true, }, } const emptyDoc = schema.nodes.doc.createAndFill()! From 51281e501411bf26f08e36ec7fc4635792438d1a Mon Sep 17 00:00:00 2001 From: asouqi Date: Wed, 17 Jun 2026 16:32:27 +0300 Subject: [PATCH 08/11] review updates --- .../V2/Document/DocumentController.ts | 7 +-- src/DIContainer/DIContainer.ts | 3 +- src/DomainServices/AuthorityService.ts | 19 +++++-- src/DomainServices/DocumentService.ts | 18 ------ src/DomainServices/StepAccessService.ts | 38 +++++++++---- src/Models/AccessContextModels.ts | 25 --------- src/Models/CapabilitiesModels.ts | 24 -------- src/Models/NodeAccessModels.ts | 24 -------- .../NodeAccess/CommentAccessPolicy.ts | 55 ------------------- .../NodeAccess/DefaultNodeAccessPolicy.ts | 33 ----------- .../NodeAccess/NodeAccessRegistry.ts | 55 ------------------- .../DomainLayer/V2/StepAccessService.spec.ts | 46 ++++++++++------ 12 files changed, 72 insertions(+), 275 deletions(-) delete mode 100644 src/Models/AccessContextModels.ts delete mode 100644 src/Models/CapabilitiesModels.ts delete mode 100644 src/Models/NodeAccessModels.ts delete mode 100644 src/Utilities/NodeAccess/CommentAccessPolicy.ts delete mode 100644 src/Utilities/NodeAccess/DefaultNodeAccessPolicy.ts delete mode 100644 src/Utilities/NodeAccess/NodeAccessRegistry.ts diff --git a/src/Controller/V2/Document/DocumentController.ts b/src/Controller/V2/Document/DocumentController.ts index b1fd5c59..aa33e996 100644 --- a/src/Controller/V2/Document/DocumentController.ts +++ b/src/Controller/V2/Document/DocumentController.ts @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { schemaToCapabilityAdapter } from '@manuscripts/transform' import { ProjectPermission } from '../../..//Models/ProjectModels' import { DIContainer } from '../../../DIContainer/DIContainer' import { DocumentPermission } from '../../../DomainServices/DocumentService' @@ -105,14 +104,14 @@ export class DocumentController extends BaseController { projectID, DocumentPermission.WRITE ) - const capabilities = await DIContainer.sharedContainer.documentService.getCapabilities( + const permittedActions = await DIContainer.sharedContainer.authorityService.getPermittedActions( projectID, user.id ) + return await DIContainer.sharedContainer.authorityService.receiveSteps(manuscriptID, payload, { userId: user.id, - capabilities, - adapter: schemaToCapabilityAdapter + actions: permittedActions, }) } diff --git a/src/DIContainer/DIContainer.ts b/src/DIContainer/DIContainer.ts index 838e5c17..8dc1aa68 100644 --- a/src/DIContainer/DIContainer.ts +++ b/src/DIContainer/DIContainer.ts @@ -42,7 +42,6 @@ import { } from '../Models/RepositoryModels' import { IServer } from '../Server/IServer' import { Server } from '../Server/Server' -import { createNodeAccessRegistry } from '../Utilities/NodeAccess/NodeAccessRegistry' const prisma = new PrismaClient() @@ -121,7 +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(createNodeAccessRegistry()) + this.stepAccessService = new StepAccessService() } /** diff --git a/src/DomainServices/AuthorityService.ts b/src/DomainServices/AuthorityService.ts index 47072a68..4d6d1372 100644 --- a/src/DomainServices/AuthorityService.ts +++ b/src/DomainServices/AuthorityService.ts @@ -14,13 +14,19 @@ * limitations under the License. */ -import { getVersion, JSONProsemirrorNode, ManuscriptActions, schema } from '@manuscripts/transform' +import { + getVersion, + JSONProsemirrorNode, + ManuscriptActions, + NodeAccessSubject, + 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' @@ -31,7 +37,7 @@ export class AuthorityService { public async receiveSteps( documentID: string, receiveSteps: ReceiveSteps, - accessContext: AccessContext + accessContext: NodeAccessSubject ): Promise { const found = await this.repository.manuscriptDoc.findDocument(documentID) const { doc, modifiedSteps } = this.applyStepsToDocument( @@ -87,7 +93,10 @@ export class AuthorityService { return history } - public async getPermittedActions(projectID: string, userID: string): Promise> { + public async getPermittedActions( + projectID: string, + userID: string + ): Promise> { const project = await DIContainer.sharedContainer.projectService.getProject(projectID) const role = DIContainer.sharedContainer.projectService.getUserRole(project, userID) @@ -129,7 +138,7 @@ export class AuthorityService { jsonSteps: Prisma.JsonObject[], document: Prisma.JsonValue, clientID: string, - accessContext: AccessContext + accessContext: NodeAccessSubject ) { const steps = this.hydrateSteps(jsonSteps) const modifiedSteps: ModifiedStep[] = [] diff --git a/src/DomainServices/DocumentService.ts b/src/DomainServices/DocumentService.ts index 82b255f9..0423bfe6 100644 --- a/src/DomainServices/DocumentService.ts +++ b/src/DomainServices/DocumentService.ts @@ -22,7 +22,6 @@ import { ErrorEvent, WebSocket, WebSocketServer } from 'ws' import { DIContainer } from '../DIContainer/DIContainer' import { MissingManuscriptError, RoleDoesNotPermitOperationError } from '../Errors' -import { Capabilities } from '../Models/CapabilitiesModels' import { ProjectUserRole } from '../Models/ProjectModels' import { Snapshot } from '../Models/SnapshotModels' import { validateToken } from '../Utilities/JWT/LoginTokenPayload' @@ -64,23 +63,6 @@ export class DocumentService { return EMPTY_PERMISSIONS } - async getCapabilities(projectID: string, userID: string): Promise { - const project = await DIContainer.sharedContainer.projectService.getProject(projectID) - const role = DIContainer.sharedContainer.projectService.getUserRole(project, userID) - const isViewer = role === ProjectUserRole.Viewer - const isOwner = role === ProjectUserRole.Owner - const isEditor = role === ProjectUserRole.Editor - - return { - handleOwnComments: !isViewer, - handleOthersComments: isOwner, - resolveOwnComment: !isViewer, - resolveOthersComment: isOwner || isEditor, - createComment: !isViewer, - editArticle: !isViewer, - } - } - public async validateUserAccess( userID: string, projectID: string, diff --git a/src/DomainServices/StepAccessService.ts b/src/DomainServices/StepAccessService.ts index f75d1abc..b8ab7b4d 100644 --- a/src/DomainServices/StepAccessService.ts +++ b/src/DomainServices/StepAccessService.ts @@ -13,20 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ManuscriptNode } from '@manuscripts/transform' +import { getNodeAccessPolicy, ManuscriptNode, NodeAccessSubject } from '@manuscripts/transform' import { AttrStep, ReplaceAroundStep, ReplaceStep, Step } from 'prosemirror-transform' -import { AccessContext } from '../Models/AccessContextModels' -import { NodeAccessRegistry } from '../Utilities/NodeAccess/NodeAccessRegistry' - type ExposedSlice = T & { insertAt: (pos: number, fragment: F) => T } export class StepAccessService { - constructor(private readonly registry: NodeAccessRegistry) {} - validate(step: Step, doc: ManuscriptNode, context: AccessContext) { + validate(step: Step, doc: ManuscriptNode, context: NodeAccessSubject) { if (step instanceof ReplaceAroundStep) { const gap = doc.slice(step.gapFrom, step.gapTo) const slice = ( @@ -45,26 +41,28 @@ export class StepAccessService { return true } - private validateReplaceStep(step: ReplaceStep, doc: ManuscriptNode, context: AccessContext) { + private validateReplaceStep(step: ReplaceStep, doc: ManuscriptNode, context: NodeAccessSubject) { 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.registry.canEditAttr(nodeDB, attr, context) + (attr) => !this.attrPolicy(nodeDB, attr, context) ) } let hasAccess = true doc.slice(step.from, step.to).content.descendants((node) => { - if (!this.registry.canDeleteNode(node, context)) { + const deletePolicy = getNodeAccessPolicy(node.type)?.delete + if (deletePolicy && !deletePolicy(node, context)) { hasAccess = false return false } }) step.slice.content.descendants((node) => { - if (!this.registry.canInsertNode(node, context)) { + const insertPolicy = getNodeAccessPolicy(node.type)?.insert + if (insertPolicy && !insertPolicy(node, context)) { hasAccess = false return false } @@ -73,9 +71,9 @@ export class StepAccessService { return hasAccess } - private validateAttrStep(step: AttrStep, doc: ManuscriptNode, context: AccessContext) { + private validateAttrStep(step: AttrStep, doc: ManuscriptNode, context: NodeAccessSubject) { const node = doc.nodeAt(step.pos) - return !!(node && this.registry.canEditAttr(node, step.attr, context)) + return this.attrPolicy(node, step.attr, context) } private isStepUpdateNodeAttr(step: ReplaceStep, doc: ManuscriptNode) { @@ -98,4 +96,20 @@ export class StepAccessService { }) return keys } + + private attrPolicy(node: ManuscriptNode | null, attr: string, context: NodeAccessSubject) { + const policy = node?.type && getNodeAccessPolicy(node.type)?.attrs + + if (policy) { + // we could have a policy that applied to all attribute changes + if (typeof policy === 'function') { + return policy(node, context) + } else { + // apply policy per-attribute + return !!policy[attr]?.(node, context) + } + } + + return true + } } diff --git a/src/Models/AccessContextModels.ts b/src/Models/AccessContextModels.ts deleted file mode 100644 index a6a22fa6..00000000 --- a/src/Models/AccessContextModels.ts +++ /dev/null @@ -1,25 +0,0 @@ -/*! - * © 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 { SchemaAdapter } from '@manuscripts/transform' - -import { Capabilities } from './CapabilitiesModels' - -export interface AccessContext { - userId: string - capabilities: Capabilities - adapter: SchemaAdapter -} diff --git a/src/Models/CapabilitiesModels.ts b/src/Models/CapabilitiesModels.ts deleted file mode 100644 index d1d10762..00000000 --- a/src/Models/CapabilitiesModels.ts +++ /dev/null @@ -1,24 +0,0 @@ -/*! - * © 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. - */ - -export type Capabilities = { - handleOwnComments: boolean - resolveOwnComment: boolean - handleOthersComments: boolean - resolveOthersComment: boolean - createComment: boolean - editArticle: boolean -} diff --git a/src/Models/NodeAccessModels.ts b/src/Models/NodeAccessModels.ts deleted file mode 100644 index da1e9053..00000000 --- a/src/Models/NodeAccessModels.ts +++ /dev/null @@ -1,24 +0,0 @@ -/*! - * © 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 { ManuscriptNode } from '@manuscripts/transform' - -import { AccessContext } from './AccessContextModels' - -export interface NodeAccessPolicy { - canInsertNode(node: ManuscriptNode, context: AccessContext): boolean - canDeleteNode(node: ManuscriptNode, context: AccessContext): boolean - canEditAttr(node: ManuscriptNode, attr: string, context: AccessContext): boolean -} diff --git a/src/Utilities/NodeAccess/CommentAccessPolicy.ts b/src/Utilities/NodeAccess/CommentAccessPolicy.ts deleted file mode 100644 index d7a9ef87..00000000 --- a/src/Utilities/NodeAccess/CommentAccessPolicy.ts +++ /dev/null @@ -1,55 +0,0 @@ -/*! - * © 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 { ManuscriptNode } from '@manuscripts/transform' - -import { AccessContext } from '../../Models/AccessContextModels' -import { NodeAccessPolicy } from '../../Models/NodeAccessModels' - -export class CommentAccessPolicy implements NodeAccessPolicy { - canInsertNode(_node: ManuscriptNode, context: AccessContext): boolean { - return context.capabilities.createComment - } - - canDeleteNode(node: ManuscriptNode, context: AccessContext): boolean { - return context.adapter.getNodeOwnerId(node) === context.userId - ? context.capabilities.handleOwnComments - : context.capabilities.handleOthersComments - } - - canEditAttr(node: ManuscriptNode, attr: string, context: AccessContext): boolean { - const attributeCapabilities = context.adapter.getRequiredCapabilityAttr(node, attr) - if (!attributeCapabilities) { - return false - } - - const isOwn = context.adapter.getNodeOwnerId(node) === context.userId - - if (isOwn) { - return ( - (attributeCapabilities.has('handleOwnComments') && - context.capabilities.handleOwnComments) || - (attributeCapabilities.has('resolveOwnComment') && context.capabilities.resolveOwnComment) - ) - } - - return ( - (attributeCapabilities.has('handleOthersComments') && - context.capabilities.handleOthersComments) || - (attributeCapabilities.has('resolveOthersComment') && - context.capabilities.resolveOthersComment) - ) - } -} diff --git a/src/Utilities/NodeAccess/DefaultNodeAccessPolicy.ts b/src/Utilities/NodeAccess/DefaultNodeAccessPolicy.ts deleted file mode 100644 index 23204cb9..00000000 --- a/src/Utilities/NodeAccess/DefaultNodeAccessPolicy.ts +++ /dev/null @@ -1,33 +0,0 @@ -/*! - * © 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 { ManuscriptNode } from '@manuscripts/transform' - -import { AccessContext } from '../../Models/AccessContextModels' -import { NodeAccessPolicy } from '../../Models/NodeAccessModels' - -export class DefaultNodeAccessPolicy implements NodeAccessPolicy { - canDeleteNode(_: ManuscriptNode, context: AccessContext): boolean { - return context.capabilities.editArticle - } - - canEditAttr(_: ManuscriptNode, __: string, context: AccessContext): boolean { - return context.capabilities.editArticle - } - - canInsertNode(_: ManuscriptNode, context: AccessContext): boolean { - return context.capabilities.editArticle - } -} diff --git a/src/Utilities/NodeAccess/NodeAccessRegistry.ts b/src/Utilities/NodeAccess/NodeAccessRegistry.ts deleted file mode 100644 index 18ebb9b9..00000000 --- a/src/Utilities/NodeAccess/NodeAccessRegistry.ts +++ /dev/null @@ -1,55 +0,0 @@ -/*! - * © 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 { ManuscriptNode, ManuscriptNodeType, schema } from '@manuscripts/transform' - -import { AccessContext } from '../../Models/AccessContextModels' -import { NodeAccessPolicy } from '../../Models/NodeAccessModels' -import { CommentAccessPolicy } from './CommentAccessPolicy' -import { DefaultNodeAccessPolicy } from './DefaultNodeAccessPolicy' - -export class NodeAccessRegistry { - private policies = new Map() - private readonly defaultPolicy: NodeAccessPolicy - - constructor(defaultPolicy?: NodeAccessPolicy) { - this.defaultPolicy = defaultPolicy ?? new DefaultNodeAccessPolicy() - } - - register(nodeType: ManuscriptNodeType, policy: NodeAccessPolicy): this { - this.policies.set(nodeType, policy) - return this - } - - getPolicy(nodeType: ManuscriptNodeType): NodeAccessPolicy { - return this.policies.get(nodeType) ?? this.defaultPolicy - } - - canInsertNode(node: ManuscriptNode, context: AccessContext): boolean { - return this.getPolicy(node.type).canInsertNode(node, context) - } - - canDeleteNode(node: ManuscriptNode, context: AccessContext): boolean { - return this.getPolicy(node.type).canDeleteNode(node, context) - } - - canEditAttr(node: ManuscriptNode, attr: string, context: AccessContext): boolean { - return this.getPolicy(node.type).canEditAttr(node, attr, context) - } -} - -export function createNodeAccessRegistry(): NodeAccessRegistry { - return new NodeAccessRegistry().register(schema.nodes.comment, new CommentAccessPolicy()) -} diff --git a/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts b/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts index 963c246e..e6cc0187 100644 --- a/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts +++ b/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts @@ -14,32 +14,42 @@ * limitations under the License. */ -import { schema} from '@manuscripts/transform' +import { NodeAccessSubject, schema } from '@manuscripts/transform' import { Transform } from 'prosemirror-transform' import { DIContainer } from '../../../../../src/DIContainer/DIContainer' -import { AccessContext } from '../../../../../src/Models/AccessContextModels' import { TEST_TIMEOUT } from '../../../../utilities/testSetup' jest.setTimeout(TEST_TIMEOUT) -let accessContext: AccessContext +let accessContext: NodeAccessSubject let tr: Transform beforeEach(async () => { ;(DIContainer as any)._sharedContainer = null await DIContainer.init() - accessContext = { - userId: 'MPUserProfile:01', - capabilities: { - resolveOthersComment: true, - resolveOwnComment: true, - handleOthersComments: true, - handleOwnComments: true, - createComment: true, - editArticle: true, - }, - } + 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)) @@ -65,7 +75,7 @@ describe('StepAccessService', () => { describe('validate', () => { it('has no access to resolve other comment', () => { accessContext.userId = 'MPUserProfile:02' - accessContext.capabilities.resolveOthersComment = false + accessContext.actions.resolveOthersComment = false tr.setNodeMarkup(10, undefined, { ...comment.attrs, resolved: true }) const hasAccessToStep = DIContainer.sharedContainer.stepAccessService.validate( tr.steps[1], @@ -75,7 +85,7 @@ describe('StepAccessService', () => { expect(hasAccessToStep).toEqual(false) }) it('has no access to resolve own comment', () => { - accessContext.capabilities.resolveOwnComment = false + accessContext.actions.resolveOwnComment = false tr.setNodeMarkup(10, undefined, { ...comment.attrs, resolved: true }) const hasAccessToStep = DIContainer.sharedContainer.stepAccessService.validate( tr.steps[1], @@ -85,7 +95,7 @@ describe('StepAccessService', () => { expect(hasAccessToStep).toEqual(false) }) it('has no access to create a comment', () => { - accessContext.capabilities.createComment = false + accessContext.actions.createComment = false tr.insert(10, schema.nodeFromJSON(comment)) const hasAccessToStep = DIContainer.sharedContainer.stepAccessService.validate( tr.steps[1], @@ -95,7 +105,7 @@ describe('StepAccessService', () => { expect(hasAccessToStep).toEqual(false) }) it('has no access to delete a comment', () => { - accessContext.capabilities.handleOwnComments = false + accessContext.actions.handleOwnComments = false tr.delete(10, 11) const hasAccessToStep = DIContainer.sharedContainer.stepAccessService.validate( tr.steps[1], From c3a800e700ae35234fa579993c968e854137251b Mon Sep 17 00:00:00 2001 From: asouqi Date: Wed, 17 Jun 2026 17:00:38 +0300 Subject: [PATCH 09/11] review updates --- pnpm-lock.yaml | 53 +++++++++++-------- src/DomainServices/AuthorityService.ts | 6 +-- src/DomainServices/StepAccessService.ts | 15 +++--- .../DomainLayer/V2/StepAccessService.spec.ts | 7 +-- 4 files changed, 41 insertions(+), 40 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36f62580..2b06c14c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4218,6 +4218,9 @@ packages: prosemirror-model@1.25.0: resolution: {integrity: sha512-/8XUmxWf0pkj2BmtqZHYJipTBMHIdVjuvFzMvEoxrtyGNmfvdhBiRwYt/eFwy2wA9DtBW3RLqvZnjurEkHaFCw==} + prosemirror-model@1.25.1: + resolution: {integrity: sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg==} + prosemirror-state@1.4.3: resolution: {integrity: sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==} @@ -6410,25 +6413,25 @@ snapshots: '@jridgewell/sourcemap-codec': 1.4.14 ? '@manuscripts/eslint-config@0.5.1(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1)(typescript@4.9.5))(eslint-plugin-header@3.1.1(eslint@9.39.1))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1))(eslint-plugin-jest@29.0.1(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1)(jest@29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@types/node@22.19.0)(typescript@4.9.5)))(typescript@4.9.5))(eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.1))(eslint-plugin-mdx@3.6.2(eslint@9.39.1))(eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.39.1))(eslint@9.39.1)(prettier@3.6.2))(eslint-plugin-promise@7.2.1(eslint@9.39.1))(eslint-plugin-react-hooks@7.0.1(eslint@9.39.1))(eslint-plugin-react@7.37.5(eslint@9.39.1))(eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.1))(eslint@9.39.1)(typescript@4.9.5)' - : dependencies: - '@typescript-eslint/eslint-plugin': 8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1)(typescript@4.9.5) - '@typescript-eslint/parser': 5.62.0(eslint@9.39.1)(typescript@4.9.5) - eslint: 9.39.1 - eslint-config-prettier: 8.10.0(eslint@9.39.1) - eslint-plugin-header: 3.1.1(eslint@9.39.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1) - eslint-plugin-jest: 29.0.1(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1)(jest@29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@types/node@22.19.0)(typescript@4.9.5)))(typescript@4.9.5) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1) - eslint-plugin-mdx: 3.6.2(eslint@9.39.1) - eslint-plugin-prettier: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.39.1))(eslint@9.39.1)(prettier@3.6.2) - eslint-plugin-promise: 7.2.1(eslint@9.39.1) - eslint-plugin-react: 7.37.5(eslint@9.39.1) - eslint-plugin-react-hooks: 7.0.1(eslint@9.39.1) - eslint-plugin-simple-import-sort: 12.1.1(eslint@9.39.1) - prettier: 2.8.8 - typescript: 4.9.5 - transitivePeerDependencies: - - supports-color + : dependencies: + '@typescript-eslint/eslint-plugin': 8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1)(typescript@4.9.5) + '@typescript-eslint/parser': 5.62.0(eslint@9.39.1)(typescript@4.9.5) + eslint: 9.39.1 + eslint-config-prettier: 8.10.0(eslint@9.39.1) + eslint-plugin-header: 3.1.1(eslint@9.39.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1) + eslint-plugin-jest: 29.0.1(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1)(jest@29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@types/node@22.19.0)(typescript@4.9.5)))(typescript@4.9.5) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1) + eslint-plugin-mdx: 3.6.2(eslint@9.39.1) + eslint-plugin-prettier: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.39.1))(eslint@9.39.1)(prettier@3.6.2) + eslint-plugin-promise: 7.2.1(eslint@9.39.1) + eslint-plugin-react: 7.37.5(eslint@9.39.1) + eslint-plugin-react-hooks: 7.0.1(eslint@9.39.1) + eslint-plugin-simple-import-sort: 12.1.1(eslint@9.39.1) + prettier: 2.8.8 + typescript: 4.9.5 + transitivePeerDependencies: + - supports-color '@manuscripts/transform@4.3.53(prosemirror-state@1.4.3)': dependencies: @@ -10245,23 +10248,27 @@ snapshots: dependencies: orderedmap: 2.1.1 + prosemirror-model@1.25.1: + dependencies: + orderedmap: 2.1.1 + prosemirror-state@1.4.3: dependencies: - prosemirror-model: 1.25.0 + prosemirror-model: 1.25.1 prosemirror-transform: 1.10.4 prosemirror-view: 1.39.2 prosemirror-tables@1.7.1: dependencies: prosemirror-keymap: 1.2.2 - prosemirror-model: 1.25.0 + prosemirror-model: 1.25.1 prosemirror-state: 1.4.3 prosemirror-transform: 1.10.4 prosemirror-view: 1.39.2 prosemirror-transform@1.10.4: dependencies: - prosemirror-model: 1.25.0 + prosemirror-model: 1.25.1 prosemirror-utils@1.2.2(prosemirror-model@1.25.0)(prosemirror-state@1.4.3): dependencies: @@ -10270,7 +10277,7 @@ snapshots: prosemirror-view@1.39.2: dependencies: - prosemirror-model: 1.25.0 + prosemirror-model: 1.25.1 prosemirror-state: 1.4.3 prosemirror-transform: 1.10.4 diff --git a/src/DomainServices/AuthorityService.ts b/src/DomainServices/AuthorityService.ts index 4d6d1372..3e96c4cd 100644 --- a/src/DomainServices/AuthorityService.ts +++ b/src/DomainServices/AuthorityService.ts @@ -18,7 +18,7 @@ import { getVersion, JSONProsemirrorNode, ManuscriptActions, - NodeAccessSubject, + AccessContext, schema, } from '@manuscripts/transform' import { Prisma } from '@prisma/client' @@ -37,7 +37,7 @@ export class AuthorityService { public async receiveSteps( documentID: string, receiveSteps: ReceiveSteps, - accessContext: NodeAccessSubject + accessContext: AccessContext ): Promise { const found = await this.repository.manuscriptDoc.findDocument(documentID) const { doc, modifiedSteps } = this.applyStepsToDocument( @@ -138,7 +138,7 @@ export class AuthorityService { jsonSteps: Prisma.JsonObject[], document: Prisma.JsonValue, clientID: string, - accessContext: NodeAccessSubject + accessContext: AccessContext ) { const steps = this.hydrateSteps(jsonSteps) const modifiedSteps: ModifiedStep[] = [] diff --git a/src/DomainServices/StepAccessService.ts b/src/DomainServices/StepAccessService.ts index b8ab7b4d..e0d761c3 100644 --- a/src/DomainServices/StepAccessService.ts +++ b/src/DomainServices/StepAccessService.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { getNodeAccessPolicy, ManuscriptNode, NodeAccessSubject } from '@manuscripts/transform' +import { AccessContext, getNodeAccessPolicy, ManuscriptNode } from '@manuscripts/transform' import { AttrStep, ReplaceAroundStep, ReplaceStep, Step } from 'prosemirror-transform' type ExposedSlice = T & { @@ -21,8 +21,7 @@ type ExposedSlice = T & { } export class StepAccessService { - - validate(step: Step, doc: ManuscriptNode, context: NodeAccessSubject) { + validate(step: Step, doc: ManuscriptNode, context: AccessContext) { if (step instanceof ReplaceAroundStep) { const gap = doc.slice(step.gapFrom, step.gapTo) const slice = ( @@ -41,13 +40,11 @@ export class StepAccessService { return true } - private validateReplaceStep(step: ReplaceStep, doc: ManuscriptNode, context: NodeAccessSubject) { + 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) - ) + return !this.findDiff(nodeDB, node).find((attr) => !this.attrPolicy(nodeDB, attr, context)) } let hasAccess = true @@ -71,7 +68,7 @@ export class StepAccessService { return hasAccess } - private validateAttrStep(step: AttrStep, doc: ManuscriptNode, context: NodeAccessSubject) { + private validateAttrStep(step: AttrStep, doc: ManuscriptNode, context: AccessContext) { const node = doc.nodeAt(step.pos) return this.attrPolicy(node, step.attr, context) } @@ -97,7 +94,7 @@ export class StepAccessService { return keys } - private attrPolicy(node: ManuscriptNode | null, attr: string, context: NodeAccessSubject) { + private attrPolicy(node: ManuscriptNode | null, attr: string, context: AccessContext) { const policy = node?.type && getNodeAccessPolicy(node.type)?.attrs if (policy) { diff --git a/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts b/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts index e6cc0187..331cf05d 100644 --- a/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts +++ b/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { NodeAccessSubject, schema } from '@manuscripts/transform' +import { AccessContext, schema } from '@manuscripts/transform' import { Transform } from 'prosemirror-transform' import { DIContainer } from '../../../../../src/DIContainer/DIContainer' @@ -22,7 +22,7 @@ import { TEST_TIMEOUT } from '../../../../utilities/testSetup' jest.setTimeout(TEST_TIMEOUT) -let accessContext: NodeAccessSubject +let accessContext: AccessContext let tr: Transform beforeEach(async () => { @@ -33,15 +33,12 @@ beforeEach(async () => { actions: { handleSuggestion: true, rejectOwnSuggestion: true, - handleOwnComments: true, handleOthersComments: true, resolveOwnComment: true, resolveOthersComment: true, createComment: true, - canEditFiles: true, - editArticle: true, formatArticle: true, editMetadata: true, From 40a71807ba8245e2e96d9fde5efdebe4b5121c75 Mon Sep 17 00:00:00 2001 From: asouqi Date: Wed, 17 Jun 2026 17:04:44 +0300 Subject: [PATCH 10/11] revert --- pnpm-lock.yaml | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b06c14c..200e22fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6413,25 +6413,25 @@ snapshots: '@jridgewell/sourcemap-codec': 1.4.14 ? '@manuscripts/eslint-config@0.5.1(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1)(typescript@4.9.5))(eslint-plugin-header@3.1.1(eslint@9.39.1))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1))(eslint-plugin-jest@29.0.1(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1)(jest@29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@types/node@22.19.0)(typescript@4.9.5)))(typescript@4.9.5))(eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.1))(eslint-plugin-mdx@3.6.2(eslint@9.39.1))(eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.39.1))(eslint@9.39.1)(prettier@3.6.2))(eslint-plugin-promise@7.2.1(eslint@9.39.1))(eslint-plugin-react-hooks@7.0.1(eslint@9.39.1))(eslint-plugin-react@7.37.5(eslint@9.39.1))(eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.1))(eslint@9.39.1)(typescript@4.9.5)' - : dependencies: - '@typescript-eslint/eslint-plugin': 8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1)(typescript@4.9.5) - '@typescript-eslint/parser': 5.62.0(eslint@9.39.1)(typescript@4.9.5) - eslint: 9.39.1 - eslint-config-prettier: 8.10.0(eslint@9.39.1) - eslint-plugin-header: 3.1.1(eslint@9.39.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1) - eslint-plugin-jest: 29.0.1(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1)(jest@29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@types/node@22.19.0)(typescript@4.9.5)))(typescript@4.9.5) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1) - eslint-plugin-mdx: 3.6.2(eslint@9.39.1) - eslint-plugin-prettier: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.39.1))(eslint@9.39.1)(prettier@3.6.2) - eslint-plugin-promise: 7.2.1(eslint@9.39.1) - eslint-plugin-react: 7.37.5(eslint@9.39.1) - eslint-plugin-react-hooks: 7.0.1(eslint@9.39.1) - eslint-plugin-simple-import-sort: 12.1.1(eslint@9.39.1) - prettier: 2.8.8 - typescript: 4.9.5 - transitivePeerDependencies: - - supports-color + : dependencies: + '@typescript-eslint/eslint-plugin': 8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1)(typescript@4.9.5) + '@typescript-eslint/parser': 5.62.0(eslint@9.39.1)(typescript@4.9.5) + eslint: 9.39.1 + eslint-config-prettier: 8.10.0(eslint@9.39.1) + eslint-plugin-header: 3.1.1(eslint@9.39.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1) + eslint-plugin-jest: 29.0.1(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1)(typescript@4.9.5))(eslint@9.39.1)(jest@29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@types/node@22.19.0)(typescript@4.9.5)))(typescript@4.9.5) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1) + eslint-plugin-mdx: 3.6.2(eslint@9.39.1) + eslint-plugin-prettier: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.39.1))(eslint@9.39.1)(prettier@3.6.2) + eslint-plugin-promise: 7.2.1(eslint@9.39.1) + eslint-plugin-react: 7.37.5(eslint@9.39.1) + eslint-plugin-react-hooks: 7.0.1(eslint@9.39.1) + eslint-plugin-simple-import-sort: 12.1.1(eslint@9.39.1) + prettier: 2.8.8 + typescript: 4.9.5 + transitivePeerDependencies: + - supports-color '@manuscripts/transform@4.3.53(prosemirror-state@1.4.3)': dependencies: From 8b28659d6e57c5b85ec0a4105c51eec69497d2f0 Mon Sep 17 00:00:00 2001 From: asouqi Date: Mon, 22 Jun 2026 16:11:55 +0300 Subject: [PATCH 11/11] review update --- src/DomainServices/StepAccessService.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/DomainServices/StepAccessService.ts b/src/DomainServices/StepAccessService.ts index e0d761c3..8eb1441e 100644 --- a/src/DomainServices/StepAccessService.ts +++ b/src/DomainServices/StepAccessService.ts @@ -13,13 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { AccessContext, getNodeAccessPolicy, ManuscriptNode } from '@manuscripts/transform' +import { + AccessContext, + getNodeAccessPolicy, + ManuscriptNode, + ExposedSlice, +} from '@manuscripts/transform' import { AttrStep, ReplaceAroundStep, ReplaceStep, Step } from 'prosemirror-transform' -type ExposedSlice = T & { - insertAt: (pos: number, fragment: F) => T -} - export class StepAccessService { validate(step: Step, doc: ManuscriptNode, context: AccessContext) { if (step instanceof ReplaceAroundStep) { @@ -47,7 +48,7 @@ export class StepAccessService { return !this.findDiff(nodeDB, node).find((attr) => !this.attrPolicy(nodeDB, attr, context)) } - let hasAccess = true + let hasAccess = context.actions.editArticle doc.slice(step.from, step.to).content.descendants((node) => { const deletePolicy = getNodeAccessPolicy(node.type)?.delete @@ -107,6 +108,6 @@ export class StepAccessService { } } - return true + return context.actions.editMetadata } }